Skip to content

fix(cms): defer below-fold sections on SPA navigation too (#277) - #506

Open
aka-sacci-ccr wants to merge 3 commits into
mainfrom
enable-deferred-sections-on-client-nav
Open

fix(cms): defer below-fold sections on SPA navigation too (#277)#506
aka-sacci-ccr wants to merge 3 commits into
mainfrom
enable-deferred-sections-on-client-nav

Conversation

@aka-sacci-ccr

@aka-sacci-ccr aka-sacci-ccr commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

A TanStack route loader is blocking, so the !isClientNavigation gate on useAsync made the router await every section — including below-fold ones — before committing a SPA transition, meaning a site's setAsyncRenderingConfig({ foldThreshold }) silently applied to SSR only (measured on a real PDP: 20 awaited sections, 2717ms blocked, 3.41MB payload — worse than a full reload); client nav now gets the same eager/deferred split as SSR, with isClientNavigation kept as a flag for derivePageUrl and isProgrammaticFetch, and bots plus ?__deco_ssr=1 still fully eager via an unchanged isEagerRequest.

On the #277 hazard this reuses rather than opens a path: deferredPromises is never passed by either route config, so loadDeferredSection is already the only deferred path in production including SSR, and it rebuilds MatcherContext from the real request (possibly in a different isolate — which is why reExtractRawProps exists) — the one thing a second hop cannot reconstruct is which branch of a page renders at all, so a gate section must be left un-⚡ in the admin, now documented on shouldDeferSection.

The one user-visible extra is NavigationProgress, which hardcoded bg-brand-primary-500 — a token the site may not define, so on a Tailwind v4 theme resetting --color-*: initial the bar was invisible in production with no build error; it now paints through var(--color-brand-primary-500, currentColor), which keeps the brand color where the utility already worked and stays visible where it did not. Tests cover SSR/client-nav split parity, index preservation, bots and ?__deco_ssr=1 staying eager under the client-nav flag, genuine programmatic fetch staying eager, and a #277 block that probes reExtractRawProps through a registered matcher to prove cookies/UA/url reach it — plus a case asserting a context-free hop is observably different so those are not vacuous.

Note the request-volume tradeoff: a client nav goes from 1 server-fn call to 1 + N deferred POSTs as the user scrolls (edge-cacheable, and the same shape SSR already had). Self-review dropped two bundled changes that carried more risk than this fix — a default pendingComponent and a preloadDeferredFallbacks preload; see the review comment for why. Full suite green apart from 4 pre-existing draft-preview failures verified identical on a clean tree, typecheck clean, no new lint findings; not verified in a browser, so the 2717ms figure is worth re-measuring on the Miess PDP before merge.

🤖 Generated with Claude Code

A TanStack route loader is blocking: the router does not commit the
transition until the loader settles. The `!isClientNavigation` gate on
`useAsync` therefore made the router await every section — including
below-fold ones — on SPA navigation, so `setAsyncRenderingConfig({
foldThreshold })` silently applied to SSR only. Measured on a real PDP:
20 awaited sections, 2717ms blocked, 3.41MB payload — worse than a full
reload.

Client nav now gets the same eager/deferred split as SSR.
`isClientNavigation` is kept as a flag (derivePageUrl still needs it for
duplicate query params, isProgrammaticFetch for Sec-Fetch-Dest: empty),
it just no longer gates deferral. Bots and ?__deco_ssr=1 stay fully
eager via the unchanged isEagerRequest.

On the #277 hazard: `deferredPromises` is never passed by either route
config, so `loadDeferredSection` is already the only deferred path in
production, SSR included — it rebuilds MatcherContext from the real
request and this change reuses it rather than opening a new path. The
one thing a second hop cannot reconstruct is which branch of a page
renders at all, so a gate section must be left un-⚡ in the admin;
documented on shouldDeferSection. Also drops the now-dead `__nav:`
inflight bucket, whose only rationale was client-nav-is-eager.

Pending UI, all dead until now:
- NavigationProgress hardcoded `bg-brand-primary-500`, a token the site
  may not define; on Tailwind v4 with `--color-*: initial` the utility is
  never generated and the bar is invisible in production with no build
  error. Paints via inline currentColor now, with a `color` prop.
- cmsRouteConfig/cmsHomeRouteConfig default pendingComponent to the
  existing CmsPagePendingFallback; `null` opts out. Without it TanStack
  keeps the previous page on screen with zero feedback.
- Deferred sections read LoadingFallback from the sync sectionOptions
  registry, unpopulated on a SPA transition, so first paint was a
  zero-height null. Added a non-awaited client-side module warm.

Regression tests: SSR/client-nav split parity, index preservation, bots
and ?__deco_ssr=1 eager under the client-nav flag, genuine programmatic
fetch eager, and a #277 block probing reExtractRawProps (the
cross-isolate miss path that actually runs on Workers) through a
registered matcher to prove cookies/UA/url reach it — plus a case
asserting a context-free hop is observably different, so those are not
vacuous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aka-sacci-ccr
aka-sacci-ccr requested a review from a team August 25, 2026 21:01
@aka-sacci-ccr

Copy link
Copy Markdown
Contributor Author

Review

Reviewed my own PR adversarially. The core one-line change and its test coverage hold up; the bundled extras carry more regression risk than the fix itself.

Blocking

1. Defaulting pendingComponent is a silent UX regression for every existing sitecmsRoute.ts:853, cmsRoute.ts:967

With pendingMs: 200 / pendingMinMs: 300, any navigation slower than 200ms now replaces the current page with a full-page gray skeleton held for ≥300ms. Deferral makes navs faster, landing many in the 200–600ms band — precisely the worst zone. Sites that deliberately shipped no pendingComponent were getting previous-page-retained, which is generally better perceived performance than page → skeleton → page thrash. Worse, CmsPagePendingFallback renders a hardcoded hero + 4-card grid; on a catch-all route that shape is wrong for PDPs, search, and institutional pages.

The PR body justified this as "no pendingComponent reads as a frozen tab." That is true at 2.7s and false at 300ms — and this PR fixes the 2.7s case, which undercuts its own justification.

2. preloadDeferredFallbacks does not reliably do what it claims, and largely duplicates existing codecmsRoute.ts:549

Fire-and-forget inside the route loader, so there is no ordering guarantee it resolves before first paint — a race, not a fix for the zero-height-null gap. DeferredSectionWrapper already calls preloadSectionModule in a useEffect when !optionsReady (DecoPageRenderer.tsx:284-290), so the imports happen regardless. It also fans out a dynamic import for every deferred section on every navigation, partially defeating the code-splitting deferral exists to preserve. The comment also overstates the gap: applySectionConventions.ts:80 populates sectionOptions at boot for any section exporting LoadingFallback (and cache/renderJson do so incidentally).

Should fix

3. Removing the __nav: inflight split isn't required, and the comment asserts safety that wasn't establishedcmsRoute.ts:299

pageInflight is module-global, so it dedups across concurrent requests in an isolate, and the payload carries pageUrl/flags/device from the winner's matcherCtx. Cross-request leakage there is pre-existing, but the split was incidentally narrowing it, and the new comment claims the sharing is "safe" without establishing it. Nothing in this PR needs the removal.

4. NavigationProgress default color silently changes appearance for sites where it workedNavigationProgress.tsx:27

The fix is right for sites that never defined brand-primary-500, but sites that did now get near-black (whatever currentColor inherits) instead of their brand color, with no warning.

5. Factually wrong commentresolve.ts:2008

"runs server-side in the same isolate" is wrong and contradicts the existence of reExtractRawProps, whose entire purpose is the cross-isolate cache miss. The argument does not need the claim.

6. Test comment overclaimsresolve.test.ts, "resolveDeferredSectionFull on a cold cache…"

It asserts passedRequest === CTX.request, which is resolveDeferredSectionFull's own parameter. loadDeferredSection builds a new Request(pageUrl || serverUrl, { headers }), so the comment's claim about "the second hop" is not what is tested.

Minor

  • SSR has the same missing-skeleton hole for deferred sections without a LoadingFallback convention; the fix is !isServer-guarded, so the asymmetry is baked in without being called out.
  • No test covers preloadDeferredFallbacks or the inflight-key change.
  • expect(html.match(/background-color:\s*currentColor/g)).toHaveLength(2) asserts an exact markup count — breaks on any benign structural edit.
  • Empty .catch(() => {}) swallows genuine registry errors; preloadSectionComponents already try/catches per key, so the outer catch is likely unreachable.
  • aria-label="Carregando página" is hardcoded pt-BR (consistent with CmsPageErrorFallback, but not configurable).
  • Request-volume implication undocumented: a client nav goes from 1 server-fn call to 1 + N deferred POSTs as the user scrolls. Intended and matches SSR, but a real Workers-invocation increase.

What holds up

The useAsync change, the isClientNavigation retention, and the resolve.test.ts coverage — particularly the matcher-probe approach where the third case proves the first two are not vacuous, and the reExtractRawProps targeting (the branch that actually runs on Workers). Bots and ?__deco_ssr=1 staying eager is correctly covered. The workerEntry.ts comment correction is accurate.

…l fix

Self-review found the bundled pending-UI/preload changes carried more
regression risk than the one-line deferral fix they shipped with.

Blocking:
- Remove the `pendingComponent` default. With pendingMs 200 /
  pendingMinMs 300, any nav slower than 200ms replaced the page with a
  full-page skeleton held >=300ms. Deferral pulls most navs into the
  200-600ms band — exactly where that swap costs more than it buys — and
  a catch-all route cannot have one right skeleton shape. Previous-page-
  until-commit is the better default; CmsPagePendingFallback stays
  exported as an opt-in. The justification ("reads as a frozen tab") was
  true at 2.7s and false at 300ms, i.e. undone by this very PR.
- Delete preloadDeferredFallbacks. Fire-and-forget in the route loader,
  so it never guaranteed the skeleton was ready by first paint — a race,
  not a fix — and DeferredSectionWrapper's own effect already preloads
  the module when options aren't ready. It also fanned out a dynamic
  import per deferred section on every nav, working against the code-
  splitting deferral exists to preserve. Its comment overstated the gap:
  applySectionConventions populates sectionOptions at boot for any
  section exporting LoadingFallback.

Should-fix:
- Restore the `__nav:` inflight bucket. Its #277 rationale is gone, but
  pageInflight is module-global and the shared payload carries pageUrl/
  flags/device from whichever request won. That bleed is pre-existing and
  wider than this bucket, but SSR vs client-nav is the pair whose
  derivePageUrl inputs differ most, so collapsing them widened a known
  hole for no gain — and the replacement comment asserted a safety that
  was never established.
- NavigationProgress defaults to var(--color-brand-primary-500,
  currentColor) rather than bare currentColor. The bare fallback fixed
  the invisible-bar case but silently demoted sites that DID define the
  token from brand color to near-black.
- Correct a wrong comment in resolve.ts: the deferred hop may land in a
  different isolate — that is precisely why reExtractRawProps exists.
  The argument does not need the same-isolate claim.
- Scope the resolveDeferredSectionFull test comment: it asserts the
  request THIS function was handed reaches the loader; loadDeferredSection
  builds its own Request, which is not covered here.

Also drops the brittle exact-occurrence-count assertion in the
NavigationProgress test and fixes two formatter misses of my own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aka-sacci-ccr

Copy link
Copy Markdown
Contributor Author

Addressed in 5280e82.

Blocking

  1. pendingComponent default — removed. Previous-page-until-commit is restored as the behavior; CmsPagePendingFallback stays exported as an opt-in, and pendingMs/pendingMinMs now document that they are inert without it.
  2. preloadDeferredFallbacks — deleted. It was a race rather than a fix, duplicated DeferredSectionWrapper's existing effect, and worked against code-splitting.

Should fix
3. __nav: inflight bucket — restored, with a comment that states what it actually protects (module-global map sharing pageUrl/flags/device from the winning request) and admits the wider pre-existing bleed instead of claiming the sharing is safe.
4. NavigationProgress now defaults to var(--color-brand-primary-500, currentColor), so sites where the old utility worked keep their brand color instead of silently dropping to near-black.
5. resolve.ts same-isolate claim — corrected. The hop may land in a different isolate; that is why reExtractRawProps exists, and the argument does not need the claim.
6. Test comment scoped to what it asserts, noting loadDeferredSection builds its own Request and that is not covered here.

Also dropped the brittle exact-occurrence-count assertion and fixed two formatter misses of my own.

Not addressed, deliberately: the SSR-side missing-skeleton hole for deferred sections without a LoadingFallback convention is pre-existing and out of scope here (the client-side half of it went away with the preloadDeferredFallbacks deletion). The request-volume increase is now called out in the PR body. aria-label stays pt-BR, consistent with CmsPageErrorFallback.

What remains is the one-line useAsync change plus its tests, the doc corrections, and the NavigationProgress visibility fix. Still needs a real browser check on the Miess PDP before merge.

@JonasJesus42

Copy link
Copy Markdown
Contributor

Problema com scroll-to-top tardio (ex: Miess)

O PR corrige o split eager/deferred no client-nav, mas assume que o scroll voltou ao topo antes do novo DOM ser commitado. Em sites que fazem window.scrollTo(0, 0) num useEffect ou hook após o commit (ex: Miess), a sequência é:

  1. Usuário está no fundo da página A
  2. Clica num link → loader roda → novo DOM da página B commita (usuário ainda no fundo)
  3. useEffect do DeferredSectionRenderer cria o IntersectionObserver com rootMargin: "300px"
  4. Como o usuário está no fundo, todas as seções deferred estão no viewport → todos os loadDeferredSection disparam de uma vez
  5. Scroll-to-top acontece depois — mas os N POSTs já foram disparados em paralelo

Resultado: ao invés de 1 + N cargas progressivas, a navegação dispara 1 + N cargas simultâneas no commit — o oposto do esperado.

Raiz do problemaDecoPageRenderer.tsx:327: o IntersectionObserver é registrado imediatamente no useEffect. Se o elemento já estiver no viewport no mount (scroll ainda não foi ao topo), dispara na primeira callback.

Fix sugerido: expor scrollToTop em CmsRouteOptions que injeta o scroll no beforeLoad do TanStack (roda client-side antes do loader, portanto antes do commit):

// CmsRouteOptions
scrollToTop?: boolean;

// cmsRouteConfig:
...(options.scrollToTop ? {
  beforeLoad: () => {
    if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "instant" });
  },
} : {}),

Isso garante que o scroll aconteça antes do novo DOM renderizar. O PR já menciona "2717ms vale ser re-medido no PDP da Miess antes do merge" — o Miess é exatamente o caso que reproduz esse problema.

@aka-sacci-ccr

Copy link
Copy Markdown
Contributor Author

Boa análise do sintoma, mas fui verificar o timing no TanStack e o cenário não se sustenta na configuração padrão — e o snippet proposto tem um bug sério. Detalhando:

O reset de scroll roda em layout effect, antes do observer

O reset acontece no evento onRendered do router, que é emitido de dentro de um useLayoutEffect (@tanstack/react-router/dist/esm/Match.js:114-118, componente OnRendered) e consumido em router-core/dist/esm/scroll-restoration.js:121. O observer, por outro lado, é criado num useEffect passivo (DecoPageRenderer.tsx:304).

O React faz flush de todos os layout effects antes de qualquer passive effect do mesmo commit. Então com scrollRestoration: true — que é o default de createDecoRouter (sdk/router.ts:75) — o scroll já está em 0 quando o IntersectionObserver é registrado. O passo 3 da sua sequência acontece depois do scroll, não antes.

Onde o problema É real

Exatamente na configuração que você descreveu: um site que abre mão do scroll restoration do router e faz window.scrollTo(0, 0) à mão num useEffect de componente raiz. Passive effects de pai rodam depois dos filhos, então aí sim o observer vê o scroll velho e dispara a manada.

Ou seja, a pergunta pro Miess é: ele passa scrollRestoration: false, ou faz o scroll na mão? Se sim, o fix é parar de fazer na mão — não adicionar opção nova no framework para contornar um mecanismo que já existe e roda no timing certo.

O beforeLoad sugerido escala na hora do preload

beforeLoad roda também em preload: o beforeLoadFnContext carrega preload e cause: preload ? "preload" : cause (router-core/dist/esm/load-matches.js:234-247), e createDecoRouter tem defaultPreload: "intent" por default (sdk/router.ts:76). Com o snippet como está, passar o mouse por cima de um link joga a página pro topo. Precisaria no mínimo de beforeLoad: ({ preload }) => { if (!preload) ... }.

Além disso, scroll em beforeLoad sobe a página antiga antes do conteúdo novo existir, e sobe em vão se a navegação for cancelada ou redirecionar.

O que sobra de legítimo

Há um caso de manada que scroll nenhum resolve: navegação back/forward onde o router restaura uma posição funda de propósito. Aí todas as seções deferred em viewport disparam de uma vez — e isso está correto do ponto de vista de conteúdo, mas continua sendo um burst de N POSTs.

O fix pra isso é concorrência limitada no DeferredSectionWrapper (fila compartilhada, ordenada por posição no documento), que cobre o burst independente de scroll. Sou a favor — mas como PR separado. Acabei de tirar duas mudanças agregadas deste PR justamente por carregarem mais risco que o fix de uma linha; não faz sentido reabrir isso agora.

Se o Miess reproduzir o burst com scrollRestoration no default, aí é bug de framework de verdade e eu quero o repro — nesse caso minha leitura acima está errada em algum ponto.

…ts first

Enabling deferral on client nav made a latent ordering bug reachable, as
raised in review on #506. The reporter's stated cause (sites doing
window.scrollTo in a useEffect) does not apply to the site cited — it
uses createDecoRouter's default scrollRestoration — but the outcome is
real anyway, for a different reason.

TanStack resets scroll from the `onRendered` event, emitted by a
useLayoutEffect in react-router's `OnRendered` that depends on the
`resolvedLocation` store, which is itself written from another
useLayoutEffect (Transitioner). The reset therefore lands one commit
AFTER the commit that mounts the skeletons, and React flushes the mount
commit's passive effects before starting that follow-up render. Measured
ordering in the real effect topology: OBSERVE then SCROLL_RESET.

So the IntersectionObserver was evaluating intersection against the
PREVIOUS page's scroll offset. A user navigating from the bottom of a
long page had every skeleton in view at once, firing every deferred
section's serverFn POST simultaneously on commit — 1 + N in parallel
instead of progressively, the exact herd deferral exists to avoid.

Fix: observe one requestAnimationFrame later, re-checking `triggered` so
a section already resolved from cache never starts an observer, and
cancelling the frame on cleanup. Falls back to observing synchronously
where rAF is absent. Also folds the duplicated load closure (no-IO path
vs observer path) into one, since both were being edited.

Not adopting the suggested `scrollToTop` + `beforeLoad` option:
`beforeLoad` also runs on preload (beforeLoadFnContext carries `preload`
and `cause: preload ? "preload" : cause`) and createDecoRouter defaults
to `defaultPreload: "intent"`, so it would scroll to top on link hover.
It also jumps the old page before the new content exists, and scrolls in
vain on a cancelled or redirected navigation.

The regression test models the effect topology rather than mounting the
router, because the ordering is a property of React's commit/flush
sequence; it asserts the unfixed order explicitly so the gate can be
removed if React ever changes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aka-sacci-ccr

Copy link
Copy Markdown
Contributor Author

Fui checar o Miess e testar o ordering. Você estava certo no resultado; eu estava errado na conclusão. Corrigindo, e já com fix em 6f0623f.

O Miess não faz scroll na mão

src/router.tsx usa createDecoRouter sem sobrescrever scrollRestoration (ou seja, default true), e a única linha de scroll é router.options.scrollRestorationBehavior = "instant" — com um comentário explicando que sem isso o html { scroll-behavior: smooth } do app.css:105 assume e a animação é cancelada antes de sair do lugar. Os outros scrollTo/scrollIntoView do repo são todos UI local (slider, FAQ, thumbnails, glossário), nenhum é scroll-to-top de navegação.

Então a premissa do seu comentário não se aplica a esse site. Mas o resultado acontece de qualquer forma, por outro caminho.

Onde eu errei

Eu disse que o reset roda em layout effect e portanto antes do observer. A primeira metade está certa, a conclusão não: o reset vem do evento onRendered, emitido por um useLayoutEffect (OnRendered) que depende do store resolvedLocation — e esse store é escrito de dentro de outro useLayoutEffect (Transitioner, batch(() => { status.set("idle"); resolvedLocation.set(...) })).

Ou seja, o reset cai um commit depois do commit que monta os skeletons, e o React faz flush dos passive effects desse commit antes de começar o render seguinte. Reproduzi a topologia de effects e a ordem medida é:

1. transitioner:layout -> set resolvedLocation
2. OBSERVE            <- passive effect do commit A
3. SCROLL_RESET       <- layout effect do commit B

Então o observer estava sim avaliando interseção contra o offset da página anterior. Sua sequência de 4 passos está correta; só o "porquê" era outro.

Fix aplicado

observer.observe(el) agora espera um requestAnimationFrame, com re-check de triggered (seção que resolveu do cache no meio não abre observer) e cancelAnimationFrame no cleanup. Um frame basta para o reset aplicar. Teste de regressão em deferredObserverTiming.test.tsx, que asserta explicitamente a ordem sem o gate — se o React mudar o flush order, o teste vira e o gate pode sair.

Por que não o scrollToTop/beforeLoad

Mantenho essa objeção: beforeLoad roda em preload (o beforeLoadFnContext carrega preload e cause: preload ? "preload" : cause) e createDecoRouter tem defaultPreload: "intent" — passar o mouse num link jogaria a página pro topo. Além disso sobe a página antiga antes do conteúdo novo existir, e sobe em vão em navegação cancelada ou redirecionada. Corrigir no observer resolve para todos os sites sem opção nova e sem tocar em scroll.

O que fica pendente

O burst em back/forward com scroll restaurado de propósito continua: as seções em viewport disparam juntas, o que é correto em conteúdo mas ainda é um burst de N POSTs. Concorrência limitada no wrapper resolve isso independente de scroll — PR separado.

Uma ressalva honesta: o gate foi validado em jsdom, cujo timing de rAF não é modelo fiel de frame de browser. A parte estrutural (observe antes do reset) é consequência do flush order do React e essa eu confio. O gate em si vale um teste no browser no PDP do Miess — se você puder rodar, é o repro ideal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants