Skip to content

Suspense boundaries are handed promises React has not read: skeleton flashes over content on screen, and possible promise hot-swapping #1079

Open

Description

@ivogt

Tracking issue: a Suspense boundary that is already on screen can be handed a promise React has not read, and its loading() fallback then shows for about 300 ms although nothing is pending. One case is measured on main; whether boundaries also get a new promise swapped in for one React has already read is being measured now. Progress notes go in the comments.

What is measured

On main (0b498e15), production and dev: after entering a page through a fully prefetched link (or back and forward), a plain click to a sibling route replaces a layout or parallel slot that is already on screen with its own loading() fallback.

What is on screen Entry Fallback over held content Complete
layout with loading(), no loaders prefetched click 301 ms 311 ms
layout with loading() and a loader prefetched click 301 ms 310 ms
parallel slot with a loader and loading() prefetched click 302 ms over the slot page at 14 ms
any of the three plain click or document load none 16 to 18 ms

Logged cause: a prefetched tree is rendered with everything awaited, so React never reads the promises the router hands to a boundary (getMemoizedContentPromise in src/segment-content-promise.ts, the memoized Promise.all from getMemoizedLoaderPromise in src/segment-loader-promise.ts). The next plain click commits urgently, the boundary on screen receives a resolved promise React has not tracked, suspends, and React keeps a fallback on screen for 300 ms.

A fix for this case is on the local branch fix/held-boundary-fallback-flash (hand the node, and the settled array of values, instead of promises). It is not pushed yet: it changes two behaviours that need a decision (a loading() boundary new to the page with nothing pending no longer shows its fallback in production; a commit that transition({ when }) gated off no longer shows a skeleton when its data is already in hand).

The concern

We may be hot swapping the promise a Suspense boundary reads: a mounted boundary gets a different promise object for a value it was already waiting for or already had. Known candidates, read from the code and not yet measured:

  • the loaders' aggregate is memoized on the loaders' loaderData refs, so when one ref changes a new Promise.all is built and handed to the same boundary;
  • prefetch: false (in development on feat/prefetch-false): one promise per hole is created at the click and resolved once by the second request, but after that request lands the stored entry is rewritten with the second request's own promises, and a new tree is rendered for a deferred loading();
  • nothing in the test suites watches React's dev warning "A component was suspended by an uncached promise".

Proposal under evaluation

A stable deferred per hole: const defer = createDefer(timeoutMs) with defer.promise, defer.resolve, defer.reject; defer.promise is what every reader of that hole gets from the moment the store is updated until the real data arrives, so one promise is resolved once in Suspense. createDeferred in src/defer.ts (today used for handles) is the existing building block.

Plan

  1. Debug sweep: router debug logging on, React dev warnings captured across the dev suites, and a probe in the boundaries that logs every time a mounted boundary is handed a different promise, with React's status of the old and the new one. On main and on feat/prefetch-false.
  2. Prototype of the stable-promise rule on an experiment branch, measured against the same runs and against the open parity cases of prefetch: false.
  3. An independent second opinion on the proposal and on alternatives. If it disagrees with the approach, the prototype stops.
  4. Decide on a permanent guard: fail dev e2e runs on React's uncached-promise warning, and or a dev-only invariant in the boundaries.

Related

Activity

  1. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 1 (2026-10-08): work started, and the test plan for seeing these problems as they occur.

    Running

    • A debug sweep and a prototype of the stable-promise rule on a local experiment branch (experiment/stable-hole-promise, off the prefetch: false branch).
    • An independent second opinion on the proposal, with its own measurements of promise identity at the boundaries. If it disagrees with the approach, the prototype stops; the test work below stands either way.

    Why React's warning is not enough (to be verified against react-dom's dev build by the experiment): "A component was suspended by an uncached promise" fires for a promise created during render. It does not fire when a boundary is handed a different promise in a later, separate render, which is the case here. The router needs its own dev signal.

    Test plan: three layers

    1. Console guard for dev e2e runs, both apps. Every browser console warning, error and page error is recorded per test. A deny list fails the test (React's uncached-promise warning, "Cannot update a component while rendering a different component", hydration mismatches, "Maximum update depth", "A component suspended while responding to synchronous input", key warnings, router messages at error level, "Missing segments. Refetching all" outside HMR suites, and the audit messages below). A test that provokes a message on purpose declares it, and fails if the declared message does not occur. Unknown messages are written to a per-run summary, so a new kind of warning is seen the first time. Existing offenders go into a baseline file with a reason each, to be burned down.

    2. A dev-only suspense audit in the router, stripped from production builds. The boundaries report, with one prefix and the segment id: (I1) a mounted boundary handed a different thenable while the previous one was pending; (I2) a boundary that has revealed its content handed a settled promise React has not tracked; (I3) a fallback that mounts while nothing handed to that boundary is pending, and separately when the boundary had revealed content before. Counters on window in dev so tests assert numbers.

    3. Named cases, each a flow with three assertions (no fallback over held content, a silent console, audit counters at zero in dev), in a dev and a production describe in both apps:

      1. prefetched entry, then a plain click to a sibling inside a held layout with loading() (no loaders; a loader; a held slot with a loader)
      2. the same after back/forward
      3. plain entry and document load as controls
      4. same-route navigation with and without transition()
      5. an action that re-runs one of two loaders read at one boundary
      6. a commit transition({ when }) gated off with its data in hand
      7. a background revalidation of a loader on screen
      8. a boundary rendered again while its data is still streaming
      9. a second click while the first navigation is pending
      10. document load and hydration with streamed boundaries
      11. an intercept opened and closed over loading() boundaries
      12. a loader read by a layout above the route that owns it
      13. prefetch: false: one promise per hole from the click to the reveal, a later plain click, back/forward to the completed page
      14. a clientUrls() route with a loader, cold and prefetched

      Plus a unit test with real React: an awaited render followed by an urgent re-render of the same boundary commits no fallback and logs nothing.

    Cases that are red today are kept as expected failures with a reason, so they show in the report and flip when fixed.

    Correction to an earlier claim in this work: e2e/same-route-nav.test.ts:230 (dev) is a race in the test on main (its skeleton sentinel can see the document's own streamed fallback), not a regression of the fix branch. Clean main fails it 4 of 6 and 8 of 9 in two separate series. The test is fixed on the fix branch.

  2. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 2 (2026-10-08): this is to be locked going forward, so the guard becomes its own change against main, separate from any fix and from prefetch: false.

    What "locked" means here: done when all of these are on main

    • Console guard on every dev e2e test in both apps, with a deny list, a helper to declare a message a test provokes on purpose, a per-run summary of unknown messages, and a baseline file with a reason per entry.
    • A check under tools/ (run in the CI lint job beside check:e2e-parity) that fails on a baseline entry without a reason or one whose test no longer produces the message.
    • A dev-only suspense audit in the router's boundaries (a swapped pending promise; a settled promise React has not read handed to a revealed boundary; a fallback with nothing pending), stripped from production, with unit tests on real React, including one that shows it stays silent on a correct streaming navigation.
    • The named flows (note 1, cases 1 to 12 and 14) in dev and production in both apps; the ones that are red today as expected failures that reference this issue.
    • The contract written down: docs/internal/suspense-contract.md, a row in the "If you are about to…" table of AGENTS.md, entries in the feature file map and the docs hub.
    • A static check that fails when code outside an allow-listed set of modules hands a promise to a boundary, if it can be made reliable; otherwise the reason it cannot.
    • The guard shown stable: whole dev suites run three times with identical guard results.

    Also started: a read-only inventory of every value the router creates before it is known and resolves later (PPR holes, clientUrls() quick navigation, prefetch: false gates, loader aggregates and streams, handles, action results, prefetch and history caches), with for each: who creates it, who resolves it, who waits on it, whether its identity is stable, and what happens on failure. It is the input for one shared mechanism; its summary will be posted here.

    Order of work

    1. The guard and the inventory (in progress).
    2. The fix for the measured bug on main (branch fix/held-boundary-fallback-flash), which flips cases 1 to 3.
    3. One mechanism for values resolved after the fact, designed from the inventory and the two evaluations of the stable-promise proposal, then the refactor of the existing sites onto it.
  3. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 3 (2026-10-08): a second possible cause is added to the investigation: React tree instability. A fallback can also show because a boundary was remounted (its element type, key or wrapper chain changed between two renders), not because of the promise it reads.

    Known history of that class here: the "keys never drift" rule in packages/rangojs-router/docs/tree-structure.md; #995 (a transition({ when }) false navigation changed the segment key and the <ViewTransition> wrapper and remounted the route twice); navigation always rendering with the client's renderSegments and never the server-rendered root, because component references differ. One remount was measured in this work: an adopted prefetch: false click with a slow layout handler unmounted and mounted the section's fallback again (fixed on that branch since).

    Added to the work in progress:

    • Sweep: mount and unmount of every boundary instance with its segment id, key and wrapper chain, on main and on the feature branch; every fallback seen is classified as an unread promise, a swapped promise, a remount, or real pending work.
    • Audit, two more invariants: a boundary for a segment that stays on the page is unmounted and mounted again; the wrapper chain (element types and keys) of a segment present in two consecutive trees differs. The documented exceptions (a route remounted on a param change outside a transition scope) are encoded as rules, not flagged.
    • Cases 15 to 22: transition({ when }) flipping true, false, true; same-route navigation inside and outside a transition scope; the full-update path against the partial path to the same page; document load followed by the first client navigation; an intercept or slot coming and going; action refetch, revalidation and back/forward of the same page; the prefetch: false placeholder tree against the tree its second request renders; a clientUrls() optimistic presentation replaced by the server's answer. Each asserts mount counts of held elements.
    • Inventory: one more field per mechanism, whether the tree shape differs before and after the value is resolved.
  4. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 4 (2026-10-08): the inventory of values the router creates before they are known and resolves later. 26 mechanisms. It was made by reading the code on feat/prefetch-false (bfacee1f, which is main plus that feature); every effect on React at run time is inferred until the running sweep measures it. Paths are relative to packages/rangojs-router/src. "FB" means it exists on the feature branch only.

    Mechanisms on the browser path

    Mechanism Shape Resolved by Reader Same object until settled Kept in a store Reject or timeout
    prefetch: false gates (FB) hand-written { promise, resolve } the second request Suspender, LoaderResolver, useLoader, slots yes while pending; the stored entry is rewritten to another promise afterwards history entry none; a cancelled one never settles
    held adoption root (FB) a promise that never settles replaced by the second request's update NavigationProvider yes state never settles, by design
    clientUrls() optimistic destination state, useDeferredValue, one shared never-settling promise replaced by the server's commit useLoader yes, then a different promise module never settles, by design
    loader aggregate (getMemoizedLoaderPromise) Promise.all in a cache keyed on the loaders' refs its members LoaderResolver; awaited on the awaited lanes sometimes module cache no
    content promise (getMemoizedContentPromise) Promise.resolve per component ref already settled Suspender per component ref module cache n/a
    per-loader streams Flight promise a stream row useLoader per commit history cache errors in band, no client timeout
    loader-store pending streams a set of promises with useOptimistic settle useLoader isLoading by promise object module no
    _rsc_loader / load() fetch with frozen snapshots the response useLoader, useFetchLoader snapshots module request-id gating
    root and initial tree native promise renderSegments NavigationProvider yes state no
    navigation payload, streamComplete, fullyPrefetched native promise and a deferred end of the stream bridge new per fetch no abort; never settles on reload
    prefetch cache and in-flight entries native promise, a deferred, a respawn per adoption decode navigation client a new promise per adoption yes 30 s timeout
    server action native promise and a deferred stream complete the caller and the follow-up render n/a module abort only
    handles (createDeferred) deferred and waiters the resolver or its timeout handle stream, useHandle snapshots history flags 30 s default
    history restore, stale revalidation settled values awaited render tree build values history cache not traced
    intercepts and parallel-slot loaders Promise.all and a field mutated on the segment its members renderSlotContent sometimes segment object no

    Server and build side (the PPR masked promise and capture gate, fragment passthrough, the "use cache" leader envelope, the render barrier, ssr: false awaited loaders, discovery gates) are in the full inventory; none of them hands a promise to a browser boundary directly.

    How a deferred is built today

    • About 12 hand-written resolver captures (let resolve!; new Promise(...)): browser/partial-update.ts:175 (FB), browser/navigation-client.ts:242, browser/prefetch/fetch.ts:301, browser/server-action-bridge.ts:317, browser/rsc-router.tsx:653, server/request-context.ts:1850, cache/cache-runtime.ts:870, ssr/index.tsx:850, rsc/shell-capture.ts, rsc/capture-queue.ts:136, server/handle-store.ts:704, and two at build time.
    • createDeferred (defer.ts:87): one production caller, the only one with a timeout.
    • Seven promises that never settle on purpose: partial-update.ts:1211 (FB), client-urls/client-root.tsx:30, browser/navigation-client.ts:228, browser/response-adapter.ts:77, browser/server-action-bridge.ts:419 and :446, router/segment-resolution/mask-nested.ts:26.

    Where a reader gets a new promise for a value it was already waiting on

    • The prefetch: false gate is replaced by the second request's own promise in the stored entry (partial-update.ts:589,620,644). Code path read end to end.
    • The clientUrls() never-settling stream is replaced by the destination's Flight stream (client-root.tsx:30,165-208, use-loader.tsx:155-204).
    • mergeSegmentLoaders builds a fresh Promise.all (merge-segment-loaders.ts:33-46).
    • getMemoizedLoaderPromise builds a new aggregate whenever any loader's ref changes (segment-loader-promise.ts:111-170); expected for a revalidation.
    • A prefetch entry is decoded again per adoption (prefetch/cache.ts:265-268, prefetch/fetch.ts:93).
    • A parallel slot's loaderDataPromise is mutated on a shared segment object (segment-system.tsx:787,801, read at client.tsx:49-60).

    Where a boundary on screen gets a settled promise React has not read

    • The shared resolved promise for a boundary with no loaders (segment-loader-promise.ts on main). Measured: 300 ms of fallback.
    • A new Promise.resolve(component) per component ref on the lanes that do not await it: action, plain streaming, commits gated off as urgent (segment-content-promise.ts:48-67, partial-update.ts:1318-1328).
    • The second request's stream stored in the entry and aggregated later (partial-update.ts:620, FB).

    Where the tree shape can differ before and after

    • A prefetch: false unit under transition() when the adoption is not held: the placeholder has no <ViewTransition> element until the real segment renders (segment-system.tsx:582-595). Inferred.
    • renderSlotContent chooses RouteContentWrapper only when the slot has loading or its component is a promise (client.tsx:36-43): a slot whose component is a promise in one render and a node in the next changes element type. Code verified, scenario inferred.
    • StreamedLoaderErrorBoundary exists only when some loader has data (segment-system.tsx:487,606).
    • A settled redirect or not-found replaces the subtree (segment-system.tsx:643-649,728-733,823).
    • By design: a clientUrls() cross-route swap remounts the route subtree; error and not-found segments always use param-bearing keys.
    • Same by construction: awaited, streaming and aggregate lanes; node against promise content inside RouteContentWrapper; all browser paths render with the client's renderSegments, the server's root is not used.

    Not traced to the end

    • A back/forward restore of an entry that holds a pending or aborted stream (navigation-bridge.ts, the awaited renderSegments in the popstate handler): a stored promise that never settles could hang the restore.
    • Gates for placeholders the second request does not answer (partial-update.ts:571-575): no test found.
    • The shell HIT fragment splice and the resume-time hole fill (rsc/shell-serve.ts), prerender/on-demand.ts, prerender/warm.ts, browser/action-coordinator.ts, browser/react/use-action.ts.
  5. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 5 (2026-10-08): the independent evaluation is in. It does not support the stable-deferred proposal, so the prototype is stopped. The guard work continues, and the evaluation's first recommendation is that same guard.

    Measured in dev on the router test-app at feat/prefetch-false (bfacee1f), StrictMode on and off, with a probe at every boundary that logs each change of the promise or value it is handed and every mount and unmount. About 50 flows: plain and prefetched clicks for the prefetch: false cases, a later plain click from a prefetched page, and the bug on main reproduced with its fixtures.

    Hypothesis 1: a boundary's promise is hot-swapped

    Not supported for the flows measured. No mounted boundary was handed a different promise for a value React had already read as pending.

    • The prefetch: false holes already have one stable promise each, created at the click and resolved once (browser/partial-update.ts:175-181, armed at :339-351).
    • Identity changes do occur and are harmless: a gate is replaced by the second request's Flight chunk, which carries its own status and is read synchronously.
    • The real defect is narrower: a boundary on screen receives a resolved native promise React has never read, in a render that cannot wait. Measured on the main bug: Suspender handed node then untracked promise at 6 ms, fallback from 7 to 309 ms; the loader aggregate handed array then untracked promise, fallback from 7 to 308 ms. React's use() marks an untracked promise pending and suspends (react-dom-client.development.js:6306-6336); one that already carries status: "fulfilled" is read synchronously (:6295-6297).
    • A stable deferred does not help there. The memoized content promise is already stable; React never read it because the prefetched render awaited everything and handed the node. Handing the node and the settled array (branch fix/held-boundary-fallback-flash) is the right fix.

    Hypothesis 2: tree instability

    Not supported for the flows measured. With StrictMode off: no unmount of LoaderBoundary, LoaderResolver, RouteContentWrapper or Suspender after the click in 26 flows, no DOM remount of a held element, and the wrapper chain (types and keys) is identical between the prefetched page's render, the second request's render and the plain click's render. With StrictMode on (the router's dev default) every boundary logs mount, unmount, mount within 1 ms: React's double invoke, easy to mistake for a remount.

    Not covered by these runs: actions, revalidation, intercepts, clientUrls(), production builds. The whole-suite sweep covers those.

    The proposal, point by point

    Part Verdict
    One stable promise per hole, resolved once already the case
    reject removes no swap; a rejected gate is an unhandled rejection of every aggregate built over it, and a failed second request already replaces the page the way a failed plain click does
    timeout only the second request resolves a gate, and it does so on every path; a timeout can only fire during a slow request and make the prefetched click worse than the plain one
    keep the gate as the stored value after the second request lands would create the main bug on every later render from that entry: a resolved gate is exactly a resolved native promise React has read only where a reader was mounted; the second request's Flight chunks are safe

    React's warning

    "A component was suspended by an uncached promise" appeared 0 times. By source it fires only when a component, replayed within one suspended attempt, hands use() a different thenable at the same index (react-dom-client.development.js:6262-6276). Neither case here does that, so watching React's console alone would catch nothing. The router's own dev audit is what catches this class.

    Recommendations from the evaluation, ranked

    1. The dev invariant and the e2e guard (in progress as test/suspense-guard): at the three read sites, an instance that is already mounted and receives a promise with no status reports it; dev e2e runs fail on that report.
    2. For a prefetched click React holds anyway (a transition() route, a read with no boundary): move the URL and the commit to the second request's first chunk and use the ordinary commit path. Nothing is on screen before then, so it costs nothing and closes the "URL ahead of the page" cases.
    3. A unit whose loader is read by a layout above it: resolve the unit's gate at once when its fallback was never shown.
    4. The reveal queued behind a view transition: commit a prefetched page with deferred units urgently when the plain click would, once the fix above is in.
    5. Two flagged loaders in nested <Suspense>: only committing with the response fixes it; lowest priority.

    Also resolved

    e2e/same-route-nav.test.ts:230 in dev is a race in the test on main, not a product defect: its skeleton sentinel can be installed while the document's own streamed fallback is still attached (React reveals a streamed boundary no sooner than 300 ms after the shell's first frame). main fails it 8 of 9 and 4 of 6 in two series with two workers. Fixed on the fix branch (the sentinel waits until that skeleton is gone); 0 of 18 and 3 runs of 49 passed since.

  6. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 6 (2026-10-08): the fix for the measured bug on main is open as draft #1080 (branch fix/held-boundary-fallback-flash). It is not to be merged before a decision on the two behaviour changes in its Semantics table (rows 2 and 3). Its held-boundary cases are the ones the guard keeps as expected failures on main until it lands.

  7. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 7 (2026-10-08): decision on the prefetch: false side. The evaluation's ideas 2 to 4 are being built, together with a simpler delivery model from the maintainer: one commit per click, and the second request only resolves the holes' promises from a routine outside React. No second tree render, no second tree commit.

    Today that already holds for a deferred loader. It does not for a deferred loading() block: the prefetch's placeholder stops at that block, so everything below it exists only in the second request's response and a second tree is rendered and committed. The experiment (local branch experiment/prefetch-one-commit, off feat/prefetch-false with #1080 merged in) changes the prefetch to carry one placeholder per segment the block covers, so the click commits the final tree shape.

    Three rules stay around the model:

    • a block whose fallback has been on screen since the click is released no sooner than the plain click would reveal it (the second request's stream complete, or its first chunk plus 300 ms);
    • a click React would hold anyway moves the URL and the page together, at the second request's first chunk;
    • a redirect, notFound() or error from deferred work still replaces the page.

    Measured by the 35 open parity cases per app and mode.

  8. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 8 (2026-10-08): one more rule for the contract, from the maintainer: the router hands React a tree only for a navigation or an action. Everything else is an in-place update: a store its readers subscribe to, or a pending promise read with use() for a fallback.

    Checked on main (0b498e15), the rule holds today. Tree updates come from navigation (browser/partial-update.ts), back/forward and its stale revalidation (browser/navigation-bridge.ts), actions (browser/server-action-bridge.ts), their errors, and HMR. A loader refresh re-renders its readers through loaderStore.subscribe (use-loader.tsx:302), a handle update through eventController.subscribeToHandles (browser/react/use-handle.ts:110).

    The only code that breaks it is the prefetch: false second request on its branch: a second tree for a deferred loading() block, and a same-tree update that delivers handle data. The one-commit experiment removes both: on the success path the second request makes no tree update, and what deferred handlers pushed goes into the stores. A failure of deferred work may still replace the page, as a failed navigation does.

    Added to the guard: every tree update carries a cause and is counted per cause (dev audit), and the named cases assert the count per user step: one per click or action, one more for back/forward only when the stored page was stale, zero for a loader refetch, a handle update and a prefetch.

  9. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 9 (2026-10-08): clientUrls() navigations are to follow the same one-commit rule. A read of the code on main (0b498e15; nothing was run, lane statements are inferred) shows what stands in the way. Paths are under packages/rangojs-router/src.

    A cross-route navigation inside a group today

    Step What updates
    Click: beginClientUrlNavigation sets the group's intent (client-urls/navigation.ts:187) one urgent render: pending is true, content still the origin route
    Deferred render of the intent (client-urls/client-root.tsx:144) the destination renders; each loader read gets one shared never-settling promise; hooks inside it read an optimistic location
    The server answers: tx.commit (browser/partial-update.ts:667) writes the store and the history cache and calls pushState none
    onUpdate (browser/react/NavigationProvider.tsx:432-487) one tree update; the destination instance survives (same cg:<group> key); its reader goes from the never-settling promise to the real stream
    The intent is cleared in a transition (client-urls/navigation.ts:199) pending is false

    So one navigation is two visible React updates of the destination plus the pending flag, and one promise swap at every loader read. A same-route navigation sets the intent but swaps nothing; entering or leaving a group, and back/forward, are plain navigations.

    What the server's answer carries

    Part Deliverable in place (a promise or a store, tree unchanged)
    Loader data of the destination and of the group's layouts; a loader's notFound(), redirect() or error yes
    Handle data, params, route name, location state yes (handles are written from inside onUpdate today)
    The client revalidation decisions (skip and force) yes
    Layouts or slots outside the group that the answer re-renders (a custom revalidate(), an unknown slot, an empty held list) no
    Intercept slot segments no
    An error or not-found segment, a network failure, a redirect from middleware or a handler no: these replace part of the page or start another navigation

    Open points before a design

    1. The client does not know the segment ids the server gives a group's route and its loaders (server short codes, no map on the client). A tree built at the click cannot use them without a change to what the server projects.
    2. Whether <ViewTransition> wraps the route depends on transition() and on a default the server stamps on the segment.
    3. Moving the URL at the click changes documented behaviour: a navigation the server refuses in middleware would have changed the URL first, a redirect would have to replace an entry that was pushed, and a superseded click would leave one (docs/client-urls.md:158, :183).
    4. The commit lane and the scroll decision are made after the answer today.
    5. An entry stored at the click with pending promises would hang a back/forward restore, which awaits everything, unless every pending promise settles on every exit.

    One shape that avoids point 3: keep the URL, the store and the history write at the server's answer as today, and make the group's current route a store value the group root subscribes to. The click then renders the destination once with one real pending promise per loader, and the answer resolves those promises and writes the stores, with a tree update only when the answer changes something outside the group. Not decided; it comes after the prefetch: false round so both use one routine.

  10. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 10 (2026-10-08): open drafts so nothing lives only on one machine: #1080 (the held-boundary fix) and #1081 (prefetch: false at its round 5 state, with its 35 open parity cases listed). The one-commit experiment, the guard branch and the view-transition investigation follow as drafts at their first checkpoint that passes the pre-push gate.

  11. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 11 (2026-10-08): the one-commit experiment is draft #1082, stacked on #1081. Its first checkpoint is the server side: a prefetch that defers a loading() block now sends one marked placeholder per segment and loader below it, with the page's full matched list, and nothing below the block runs. Everything that shapes that tree is static per route match; only failure signals are known when a handler runs. The client is still the two-tree one at this checkpoint. With #1080 merged in, the two nested-<Suspense> parity cases are green (33 open cases remain).

  12. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 12 (2026-10-08): the guard is draft #1083 (first checkpoint; the stability runs, the cleanup pass and an independent review are still to do). What it already shows on main:

    Existing dev tests that break a rule, now in a baseline with a reason each: 66 (router test-app 52, cloudflare-basic 14).

    Rule Tests
    A settled promise React has not read handed to content on screen (audit I2) 36
    Router messages at error level 25 (23 provoked on purpose; 2 [Browser] MISSING SEGMENT ... not in cache! that no test declares)
    Hydration mismatch 4 (1 on purpose; 3 undeclared)
    React: "called use() to suspend in a previous render but did not call use()" 1 (client-urls.test.ts)
    React's uncached-promise warning; a swapped pending promise; a remount of held content; tree shape drift; a tree update without a cause 0

    So on main the measured flash is one visible case of a wider pattern: 36 existing tests hand content on screen a promise React has not read. Inside a transition that shows nothing; on an urgent commit it is the 300 ms skeleton. Hot-swapping, remounts and tree drift are not found by the audit either, which agrees with the independent measurement in note 5.

    New cases red on main, kept as expected failures: 46 e2e and 2 unit. They include three clientUrls() cases: 2 to 4 commits and 2 promises per click, against a target of 1 and 1.

    Production cost: the audit is not in a client build (the bundle guard fails if it is), but the router chunk grew 27 B gzip from how the audited component is chosen; to be brought to zero.

    Next on that branch: the 36 grouped by commit lane (which decides the order of fixes after #1080), the causes of the undeclared messages, zero production bytes.

    Drafts now open: #1080 (held-boundary fix), #1081 (prefetch: false), #1082 (one commit per click, stacked on #1081), #1083 (the guard).

  13. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 13 (2026-10-08): the one-commit model for prefetch: false is built (draft #1082, checkpoint 2, f43a4a27). A prefetched click now builds its page once with one pending promise per hole; the second request resolves those promises and writes the handle and location-state stores, with no tree update on success (counted through the real provider: exactly one tree update per click, prefetched or plain, in 8 page shapes). A failure of deferred work is the only case that still replaces the page. Open: a deferred layout under #1080's rule (the plain click now shows the layout's chrome at once; the prefetched click shows its fallback until the second request lands: a maintainer decision), docs, and the three-run verification. The guard (#1083, checkpoint 2) now costs zero bytes in a production build; its baseline is 41 tests, 36 of them the unread-promise class of this issue.

  14. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 14 (2026-10-08): the guard (#1083) was run over #1080 merged in (local branch, not pushed). Result: no React warning for a use() called and then not called at any loader boundary (the one occurrence is the existing clientUrls() hand-over from main), no new denied message, no swap, remount or drift report. All 36 unread-promise tests of main's baseline drop to 0, and every held-boundary expected failure flips to green in dev and production in both apps. One case stays red: a route with loading() whose loaders are all read behind their own <Suspense> still shows the route's fallback in a build in the guard's fixture, while the same rule held in the prefetch: false fixture; the difference is being measured. Whichever of #1080 and #1083 merges second removes the 36 baseline entries, three expectConsole allows and the flipped expected failures.

  15. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 15 (2026-10-08): #1080 is merged (b43e80e2) after the maintainer accepted its rule (no skeleton when nothing is pending, on the condition that no boundary switches its promise in a way that flickers; the guard run over it confirmed that) and green CI (43 checks). Follow-ups that now apply: #1083 rebases and drops the 36 unread-promise baseline entries, three expectConsole allows and the flipped expected failures, and removes segment-content-promise.ts from its contract check; #1081 carries the same zero-loader commit and rebases onto main; not covered by #1080 and tracked here: a client component placed directly in a route's content still shows the route's loading() for about 300 ms on a cold click.

  16. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 16 (2026-10-08): the maintainer settled the open prefetch: false decisions (#1081, #1082).

    • A flagged layout entered from outside it: nothing in the flagged block runs in the prefetch, and the click shows that layout's skeleton at once. The layout and its content appear when the second request answers. Rejected: holding the old page until the second request's first chunk, because the maintainer prefers the skeleton sooner; running the layout's handler in the prefetch, because a handler can await ctx.use(Loader) and would run that loader in every prefetch. These cells stop being open parity cases and become asserted, documented expectations. They are the one place where a prefetched click differs from a plain one: it shows the skeleton sooner.
    • Client size: no lazy split. A working split saved 610 B gzip for apps that never defer (the router chunk went from 49,142 to 48,532 B), cost apps that defer 793 B more and one request, and needed a special case in the chunking config. The 1,690 B stays in the router chunk.
    • Stale back/forward: showing the cached entry and then committing the revalidated tree counts as one navigation. The second update commits in a transition and never shows a fallback. A back/forward with no cache entry stays a normal fetch.
    • In-flight prefetch (decision 4, earlier today): a click reuses an in-flight prefetch. When it is partial, the second request follows it, so the deferred content can arrive later than on a plain click. This is the accepted cost of the flag, which exists to keep expensive work out of prefetches.
  17. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Progress note 17 (2026-10-08): #1085 is merged (88a4b967), and the remaining prefetch: false decisions are made.

    #1085 (merged)

    • In development, a useLoader read that mounted on a stream and was later rendered with the value settled made React log "called use() to suspend in a previous render but did not call use() when it finished".
    • A read with route context now calls use() on a shared, already-resolved thenable. Reads without route context are unchanged, so a synchronous act() in tests still flushes.
    • Red on main in a browser in both apps.
    • Suspender, LoaderResolver and NavigationProvider have the same conditional use() shape. Not reproduced yet; tracked here.

    prefetch: false (#1081, #1082)

    • A loading() shows only for what it covers. A loader of a segment with no loading() of its own streams and suspends at its read site, as on main.
      • The branch had made a flagged layout wait for those loaders. That is removed, as is the batching of deferred parts outside the flagged layout.
    • The window: content up to 300 ms later than on a plain click, only when its data lands within React's 300 ms minimum after the flagged layout's reveal.
      • It is accepted as the cost of showing the skeleton at the click, and asserted as a bound page-wide, because React's minimum applies to the whole page.
    • The router's own 300 ms hold for deferred blocks under their own loading() stays. It lines their reveal up with the plain click's, and without it two cases came out 200 ms later than a plain click in all four app and mode combinations.

    Hooks and view transitions (#1078)

    • Approved: the router hooks deliver their updates through the router's commit, so the type React hands out lands on the router's own commit. In-progress state (pending, loading, streaming) is still delivered at once.
    • Being built as its own PR before b1.
    • A back/forward served from the client cache has never animated. React runs a transition started inside popstate synchronously; Next.js behaves the same.
      • The documented "navigation-back" type never played.
      • The docs are being corrected in the same PR.

    Unified defer

    • Evaluated: one resolve-only, self-stamping hole for every pending promise the router creates in the browser, plus one decoder for loader outcomes.
    • Approved as follow-ups after test(router): lock the Suspense contract: dev audit, console guard on dev e2e, named cases #1083 and prefetch: false land.
    • reject and timeout are out:
      • Errors and notFound already travel as fulfilled results that the read site throws into the nearest boundary.
      • A real rejection is reported through every Promise.all built on it.
      • A client timer cannot tell slow from dead.
  18. ivogt commented on Oct 8, 2026

    @ivogt
    CollaboratorAuthor

    Note 18: the guard is on main (#1083, b661d2f8).

    The contract in packages/rangojs-router/docs/internal/suspense-contract.md is now enforced:

    • Audit. A dev-only audit in the router reports I1 to I7. It runs only where INTERNAL_RANGO_SUSPENSE_AUDIT is set, which is this repo's e2e servers and fixtures and the router's vitest config. Consumers see nothing.
    • Console guard. Every dev e2e test in the router test-app and cloudflare-basic fails on a denied console message, unless the test declares it with expectConsole or the baseline lists it.
    • Two CI lint checks. check:e2e-console-baseline requires each baseline entry's full test title. check:suspense-contract uses the TypeScript checker to find code outside an allow-list that emits a tree update without a cause or builds boundary values.
    • No production cost. cloudflare-basic's client router chunk builds byte-identical with this router source and with the previous main's (router-B_XbGVOD.js, 47,488 B gzip).

    What main still shows, from the baseline (19 entries):

    Finding Entries Next
    I7: renderSegments writes loaderIds and loaderDataPromise on a reused parallel slot's segment object after hand-over (src/segment-system.tsx:769, :787-803) 15 build a new segment object; separate PR
    I3, intermittent: a parallel slot's loading() over a settled loader aggregate. Slots take no per-loader streams, so rule 2's timing gap is real there. 1 stamp the aggregate when it settles (dc-t14)
    MISSING SEGMENT, intermittent: an action on an intercepted page completes after a navigation to the same URL without the intercept 2 reproduces on main; the screen is right
    Hydration mismatch from a fixture's method next to a function action 1 fixture fix

    Removed on the way:

    Still open from this issue:

    In flight on top of the guard: prefetch: false (#1081, #1082), the hook commit lanes that fix #1078's type race, and streamed handler errors reaching the declared errorBoundary().

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions