- Date: 2026-05-27
- Status: Accepted
- Feature: Cross-cutting perf pattern для
/app/*route changes - Affects:
apps/web/src/app/app/layout.tsx(shared AuthGate)apps/web/src/components/persistence-loader.tsx(module cache + optimistic + persistent subs)apps/web/src/components/liveblocks-room.tsx(non-blocking sync pill)- Any future provider/loader pattern в
app/
Context
Route changes между /app/library ↔ /app/workflow ↔ /app/screen ощущались как 200–1000ms блокирующие переходы. Анализ выделил 3 независимых источника лага:
- AuthGate re-mount per page →
fetchMe()roundtrip на каждый navigation - PersistenceLoader re-fetch → "Loading project…" overlay даже когда data была уже в памяти
- LiveblocksRoom blocking sync → children не рендерились до
syncedevent (~500ms cold start)
Decision
Четыре скоординированных паттерна. Применять для любого navigation-frequent provider в app/.
Pattern 1 — Hoist auth to shared layout
Auth check монтируется один раз per session в app/app/layout.tsx. Страницы не оборачивают себя в AuthGate.
// app/app/layout.tsx
export default function AppLayout({ children }) {
return <div className="app-shell"><AuthGate>{children}</AuthGate></div>;
}Результат: zero fetchMe между routes. AuthGate живёт пока юзер в session.
Pattern 2 — Module-level cache + useState initializer
Кэш живёт на module-level, читается синхронно в useState initializer → mount instant если cache hit:
const projectCache = new Map<string, CachedSnapshot>();
const [snap, setSnap] = useState(() => {
const cached = projectCache.get(projectId);
return cached ? { ready: true, ...cached } : { ready: false };
});Cache key = identity того, что грузим (projectId). Hit = zero refetch, zero overlay, zero rerender.
Pattern 3 — Optimistic render
Children рендерятся сразу с empty seed. Data hydrates async → stores update → React re-renders с реальными данными. Connection state показывается как non-blocking pill, никогда как full-screen block.
return (
<>
{children}
{!synced ? <div className="syncing-pill">syncing…</div> : null}
</>
);Pattern 4 (counter-intuitive) — Subscriptions persist past unmount
PersistenceLoader НЕ отписывает dual-write listeners на unmount. Они живут пока юзер в session — needed для write-back при navigation. Real cleanup при logout / project change (handled выше).
return () => {
cancelled = true;
// Не отписываем subscriptions — должны жить пока user в session,
// чтобы dual-write Postgres продолжал работать при navigation.
};Этот паттерн контр-интуитивный. Без code comment future maintainer "починит" этот "leak" и сломает write-back.
Commit range
| Commit | Pattern | Эффект |
|---|---|---|
e0a20ba | 1 | Shared AuthGate в app/app/layout.tsx |
ac587a0 | 3 | LiveblocksRoom optimistic render |
df00e4d | 2 + 4 | PersistenceLoader module cache + persistent subs |
c544df2 | 3 | PersistenceLoader полный optimistic (kill loading overlay) |
Consequences
Положительные:
- Navigation feels instant на warm cache (the common case при работе в editor)
- First load всё ещё показывает кратко empty state — принятый tradeoff
Цена:
- Counter-intuitive cleanup pattern требует code-comment-уровень документации (есть в
persistence-loader.tsx:148) - Module cache живёт forever — fine для single-user session, потребует invalidation hook на project deletion (TODO когда появится delete flow в editor)
- Subscriptions leak при logout не cleanup'ятся автоматически — relies на полный page reload при logout (текущий behavior)
Re-evaluation triggers
- Memory leak observed при multi-project navigation (> 20 projects opened за session)
- Cache staleness когда same project edited из 2 tabs (зависит от Liveblocks sync, не от cache — но если sync ломается, cache усугубит)
- React 19
use()/ Suspense data fetching pattern обсолитит manual approach - Если добавится in-editor project switcher (без full reload) — потребуется явный
projectCache.delete(oldId)+ subscription cleanup
References
- Master spec §I.5 (UX latency budgets)
- ADR 0002 — Module stores +
useSyncExternalStore(foundation для Pattern 2)