fix(cms): defer below-fold sections on SPA navigation too (#277) - #506
fix(cms): defer below-fold sections on SPA navigation too (#277)#506aka-sacci-ccr wants to merge 3 commits into
Conversation
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>
ReviewReviewed 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. Blocking1. Defaulting With 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. 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- Should fix3. Removing the
4. The fix is right for sites that never defined 5. Factually wrong comment — "runs server-side in the same isolate" is wrong and contradicts the existence of 6. Test comment overclaims — It asserts Minor
What holds upThe |
…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>
|
Addressed in 5280e82. Blocking
Should fix 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 What remains is the one-line |
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
Resultado: ao invés de Raiz do problema — Fix sugerido: expor // 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. |
|
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 observerO reset acontece no evento O React faz flush de todos os layout effects antes de qualquer passive effect do mesmo commit. Então com Onde o problema É realExatamente na configuração que você descreveu: um site que abre mão do scroll restoration do router e faz Ou seja, a pergunta pro Miess é: ele passa O
|
…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>
|
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
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 erreiEu 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 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 é: 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
Por que não o
|
A TanStack route loader is blocking, so the
!isClientNavigationgate onuseAsyncmade the router await every section — including below-fold ones — before committing a SPA transition, meaning a site'ssetAsyncRenderingConfig({ 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, withisClientNavigationkept as a flag forderivePageUrlandisProgrammaticFetch, and bots plus?__deco_ssr=1still fully eager via an unchangedisEagerRequest.On the #277 hazard this reuses rather than opens a path:
deferredPromisesis never passed by either route config, soloadDeferredSectionis already the only deferred path in production including SSR, and it rebuildsMatcherContextfrom the real request (possibly in a different isolate — which is whyreExtractRawPropsexists) — 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 onshouldDeferSection.The one user-visible extra is
NavigationProgress, which hardcodedbg-brand-primary-500— a token the site may not define, so on a Tailwind v4 theme resetting--color-*: initialthe bar was invisible in production with no build error; it now paints throughvar(--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=1staying eager under the client-nav flag, genuine programmatic fetch staying eager, and a #277 block that probesreExtractRawPropsthrough 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
pendingComponentand apreloadDeferredFallbackspreload; 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