Repository navigation
Description
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'
loaderDatarefs, so when one ref changes a newPromise.allis built and handed to the same boundary; prefetch: false(in development onfeat/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 deferredloading();- 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
- 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
mainand onfeat/prefetch-false. - Prototype of the stable-promise rule on an experiment branch, measured against the same runs and against the open parity cases of
prefetch: false. - An independent second opinion on the proposal and on alternatives. If it disagrees with the approach, the prototype stops.
- 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
- A loading() skeleton delays content by up to 300 ms on a cold click; an idle view transition adds about 250 ms under transition() #1078: what a plain click pays on a
loading()route (the 300 ms fallback minimum, the idle view transition undertransition()). prefetch: false(not yet a PR): its remaining gaps against a plain click are expected failures in its parity suite; several involve when a boundary is revealed.
Activity
ivogt commentedon Oct 8, 2026
Progress note 1 (2026-10-08): work started, and the test plan for seeing these problems as they occur.
Running
experiment/stable-hole-promise, off theprefetch: falsebranch).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
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.
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
windowin dev so tests assert numbers.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:
loading()(no loaders; a loader; a held slot with a loader)transition()transition({ when })gated off with its data in handloading()boundariesprefetch: false: one promise per hole from the click to the reveal, a later plain click, back/forward to the completed pageclientUrls()route with a loader, cold and prefetchedPlus 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 onmain(its skeleton sentinel can see the document's own streamed fallback), not a regression of the fix branch. Cleanmainfails it 4 of 6 and 8 of 9 in two separate series. The test is fixed on the fix branch.ivogt commentedon Oct 8, 2026
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 fromprefetch: false.What "locked" means here: done when all of these are on
maintools/(run in the CI lint job besidecheck:e2e-parity) that fails on a baseline entry without a reason or one whose test no longer produces the message.docs/internal/suspense-contract.md, a row in the "If you are about to…" table ofAGENTS.md, entries in the feature file map and the docs hub.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: falsegates, 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
main(branchfix/held-boundary-fallback-flash), which flips cases 1 to 3.ivogt commentedon Oct 8, 2026
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 (atransition({ when })false navigation changed the segment key and the<ViewTransition>wrapper and remounted the route twice); navigation always rendering with the client'srenderSegmentsand never the server-rendered root, because component references differ. One remount was measured in this work: an adoptedprefetch: falseclick with a slow layout handler unmounted and mounted the section's fallback again (fixed on that branch since).Added to the work in progress:
mainand on the feature branch; every fallback seen is classified as an unread promise, a swapped promise, a remount, or real pending work.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; theprefetch: falseplaceholder tree against the tree its second request renders; aclientUrls()optimistic presentation replaced by the server's answer. Each asserts mount counts of held elements.ivogt commentedon Oct 8, 2026
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 ismainplus that feature); every effect on React at run time is inferred until the running sweep measures it. Paths are relative topackages/rangojs-router/src. "FB" means it exists on the feature branch only.Mechanisms on the browser path
prefetch: falsegates (FB){ promise, resolve }Suspender,LoaderResolver,useLoader, slotsNavigationProviderclientUrls()optimistic destinationuseDeferredValue, one shared never-settling promiseuseLoadergetMemoizedLoaderPromise)Promise.allin a cache keyed on the loaders' refsLoaderResolver; awaited on the awaited lanesgetMemoizedContentPromise)Promise.resolveper component refSuspenderuseLoaderuseOptimisticuseLoaderisLoading_rsc_loader/load()useLoader,useFetchLoaderrenderSegmentsNavigationProviderstreamComplete,fullyPrefetchedcreateDeferred)useHandlePromise.alland a field mutated on the segmentrenderSlotContentServer and build side (the PPR masked promise and capture gate, fragment passthrough, the
"use cache"leader envelope, the render barrier,ssr: falseawaited 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
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.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:419and:446,router/segment-resolution/mask-nested.ts:26.Where a reader gets a new promise for a value it was already waiting on
prefetch: falsegate 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.clientUrls()never-settling stream is replaced by the destination's Flight stream (client-root.tsx:30,165-208,use-loader.tsx:155-204).mergeSegmentLoadersbuilds a freshPromise.all(merge-segment-loaders.ts:33-46).getMemoizedLoaderPromisebuilds a new aggregate whenever any loader's ref changes (segment-loader-promise.ts:111-170); expected for a revalidation.prefetch/cache.ts:265-268,prefetch/fetch.ts:93).loaderDataPromiseis mutated on a shared segment object (segment-system.tsx:787,801, read atclient.tsx:49-60).Where a boundary on screen gets a settled promise React has not read
segment-loader-promise.tsonmain). Measured: 300 ms of fallback.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).partial-update.ts:620, FB).Where the tree shape can differ before and after
prefetch: falseunit undertransition()when the adoption is not held: the placeholder has no<ViewTransition>element until the real segment renders (segment-system.tsx:582-595). Inferred.renderSlotContentchoosesRouteContentWrapperonly when the slot hasloadingor 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.StreamedLoaderErrorBoundaryexists only when some loader has data (segment-system.tsx:487,606).segment-system.tsx:643-649,728-733,823).clientUrls()cross-route swap remounts the route subtree; error and not-found segments always use param-bearing keys.RouteContentWrapper; all browser paths render with the client'srenderSegments, the server's root is not used.Not traced to the end
navigation-bridge.ts, the awaitedrenderSegmentsin the popstate handler): a stored promise that never settles could hang the restore.partial-update.ts:571-575): no test found.rsc/shell-serve.ts),prerender/on-demand.ts,prerender/warm.ts,browser/action-coordinator.ts,browser/react/use-action.ts.ivogt commentedon Oct 8, 2026
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 theprefetch: falsecases, a later plain click from a prefetched page, and the bug onmainreproduced 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.
prefetch: falseholes already have one stable promise each, created at the click and resolved once (browser/partial-update.ts:175-181, armed at:339-351).statusand is read synchronously.mainbug:Suspenderhanded 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'suse()marks an untracked promise pending and suspends (react-dom-client.development.js:6306-6336); one that already carriesstatus: "fulfilled"is read synchronously (:6295-6297).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,RouteContentWrapperorSuspenderafter 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
rejectmainbug 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 safeReact'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
test/suspense-guard): at the three read sites, an instance that is already mounted and receives a promise with nostatusreports it; dev e2e runs fail on that report.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.<Suspense>: only committing with the response fixes it; lowest priority.Also resolved
e2e/same-route-nav.test.ts:230in dev is a race in the test onmain, 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).mainfails 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.ivogt commentedon Oct 8, 2026
Progress note 6 (2026-10-08): the fix for the measured bug on
mainis open as draft #1080 (branchfix/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 onmainuntil it lands.ivogt commentedon Oct 8, 2026
Progress note 7 (2026-10-08): decision on the
prefetch: falseside. 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 branchexperiment/prefetch-one-commit, offfeat/prefetch-falsewith #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:
notFound()or error from deferred work still replaces the page.Measured by the 35 open parity cases per app and mode.
ivogt commentedon Oct 8, 2026
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 throughloaderStore.subscribe(use-loader.tsx:302), a handle update througheventController.subscribeToHandles(browser/react/use-handle.ts:110).The only code that breaks it is the
prefetch: falsesecond request on its branch: a second tree for a deferredloading()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.
ivogt commentedon Oct 8, 2026
Progress note 9 (2026-10-08):
clientUrls()navigations are to follow the same one-commit rule. A read of the code onmain(0b498e15; nothing was run, lane statements are inferred) shows what stands in the way. Paths are underpackages/rangojs-router/src.A cross-route navigation inside a group today
beginClientUrlNavigationsets the group's intent (client-urls/navigation.ts:187)pendingis true, content still the origin routeclient-urls/client-root.tsx:144)tx.commit(browser/partial-update.ts:667) writes the store and the history cache and callspushStateonUpdate(browser/react/NavigationProvider.tsx:432-487)cg:<group>key); its reader goes from the never-settling promise to the real streamclient-urls/navigation.ts:199)pendingis falseSo 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
notFound(),redirect()or erroronUpdatetoday)revalidate(), an unknown slot, an empty held list)Open points before a design
<ViewTransition>wraps the route depends ontransition()and on a default the server stamps on the segment.docs/client-urls.md:158,:183).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: falseround so both use one routine.ivogt commentedon Oct 8, 2026
Progress note 10 (2026-10-08): open drafts so nothing lives only on one machine: #1080 (the held-boundary fix) and #1081 (
prefetch: falseat 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.ivogt commentedon Oct 8, 2026
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).ivogt commentedon Oct 8, 2026
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).
[Browser] MISSING SEGMENT ... not in cache!that no test declares)client-urls.test.ts)So on
mainthe 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 threeclientUrls()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).ivogt commentedon Oct 8, 2026
Progress note 13 (2026-10-08): the one-commit model for
prefetch: falseis 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.ivogt commentedon Oct 8, 2026
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 existingclientUrls()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 withloading()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 theprefetch: falsefixture; the difference is being measured. Whichever of #1080 and #1083 merges second removes the 36 baseline entries, threeexpectConsoleallows and the flipped expected failures.fix(router): a loading() boundary on screen is not replaced by its fa…
ivogt commentedon Oct 8, 2026
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, threeexpectConsoleallows and the flipped expected failures, and removessegment-content-promise.tsfrom 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'sloading()for about 300 ms on a cold click.ivogt commentedon Oct 8, 2026
Progress note 16 (2026-10-08): the maintainer settled the open
prefetch: falsedecisions (#1081, #1082).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.test(e2e): suspense contract cases in both apps, dev and production
test(e2e): cases for a stale back/forward and a route whose loaders h…
test(e2e): an outer-layout held-boundary scenario, and a client compo…
ivogt commentedon Oct 8, 2026
Progress note 17 (2026-10-08): #1085 is merged (
88a4b967), and the remainingprefetch: falsedecisions are made.#1085 (merged)
useLoaderread 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".use()on a shared, already-resolved thenable. Reads without route context are unchanged, so a synchronousact()in tests still flushes.Suspender,LoaderResolverandNavigationProviderhave the same conditionaluse()shape. Not reproduced yet; tracked here.prefetch: false(#1081, #1082)loading()shows only for what it covers. A loader of a segment with noloading()of its own streams and suspends at its read site, as on main.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)
popstatesynchronously; Next.js behaves the same."navigation-back"type never played.Unified defer
prefetch: falseland.rejectandtimeoutare out:Promise.allbuilt on it.test(e2e): suspense contract cases in both apps, dev and production
test(e2e): cases for a stale back/forward and a route whose loaders h…
test(e2e): an outer-layout held-boundary scenario, and a client compo…
test(router): lock the Suspense contract: dev audit, console guard on…
ivogt commentedon Oct 8, 2026
Note 18: the guard is on main (#1083,
b661d2f8).The contract in
packages/rangojs-router/docs/internal/suspense-contract.mdis now enforced:INTERNAL_RANGO_SUSPENSE_AUDITis set, which is this repo's e2e servers and fixtures and the router's vitest config. Consumers see nothing.expectConsoleor the baseline lists it.check:e2e-console-baselinerequires each baseline entry's full test title.check:suspense-contractuses the TypeScript checker to find code outside an allow-list that emits a tree update without a cause or builds boundary values.router-B_XbGVOD.js, 47,488 B gzip).What main still shows, from the baseline (19 entries):
renderSegmentswritesloaderIdsandloaderDataPromiseon a reused parallel slot's segment object after hand-over (src/segment-system.tsx:769,:787-803)loading()over a settled loader aggregate. Slots take no per-loader streams, so rule 2's timing gap is real there.MISSING SEGMENT, intermittent: an action on an intercepted page completes after a navigation to the same URL without the interceptmethodnext to a functionactionRemoved on the way:
clientUrls()conditionaluse()entries;Still open from this issue:
clientUrls()one-commit cases (expected failures);/sc/topon a cold click in a build (candidate a of experiment(router): the idle view transition under transition() + loading(), cause and four candidates (#1078) #1084);use()read sites (Suspender,LoaderResolver,NavigationProvider);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 declarederrorBoundary().