ADRs
ADR 0026 — Navigation performance paradigm
  • 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 независимых источника лага:

  1. AuthGate re-mount per pagefetchMe() roundtrip на каждый navigation
  2. PersistenceLoader re-fetch → "Loading project…" overlay даже когда data была уже в памяти
  3. LiveblocksRoom blocking sync → children не рендерились до synced event (~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

CommitPatternЭффект
e0a20ba1Shared AuthGate в app/app/layout.tsx
ac587a03LiveblocksRoom optimistic render
df00e4d2 + 4PersistenceLoader module cache + persistent subs
c544df23PersistenceLoader полный 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)