Repository navigation
Description
A plain click (nothing prefetched) on a route with loading() shows its content up to 300 ms later than the same route without loading(), and a route with transition() and loading() can lose another 250 ms to a view transition that changes nothing. Both are measured on main (0b498e15), in dev and in a production build.
Measurements
Throwaway fixture in e2e/test-app: one loader whose duration comes from the query, read with useLoader by a client component; links with prefetch="none"; 5 clicks per cell on fresh pages; medians, ms after the click; "complete" is the loader's value on screen. Dev is within 10 ms of production in every cell.
1. loading() and a fast loader (no transition())
| Loader takes | loader only (React holds the previous page) |
loader + loading() |
Skeleton on screen |
|---|---|---|---|
| 0 ms | 22 | 318 | 300 ms |
| 100 ms | 109 | 319 | 301 ms |
| 200 ms | 211 | 318 | 301 ms |
| 280 ms | 290 | 318 | 301 ms |
| 320 ms | 331 | 329 | 312 ms |
| 500 ms | 511 | 510 | 492 ms |
With a 0 ms loader the whole response had arrived 16 ms after the click, the skeleton appeared at 18 ms and stayed until 318 ms. With 100 ms added to the network the picture is the same, shifted: 412 against 211 for a 100 ms loader.
2. transition() + loading()
| Loader takes | transition() |
transition({ viewTransition: false }) |
View transitions started |
|---|---|---|---|
| 100 ms | 366 | 320 | 2 |
| 280 ms | 368 | 323 | 2 |
| 400 ms | 664 | 411 | 3 |
| 500 ms | 666 | 510 | 3 |
At 400 ms the three transitions run 39 to 314, 347 to 631 and 633 to 914 ms. The middle one changes nothing on screen; the content is applied by the third, which cannot start before the second has finished. The loader's value was in the browser at 406 ms.
Two loaders on one route (100 ms and 400 ms), read under the same loading(): 3 transitions, complete at 665 ms against 412 ms with viewTransition: false.
3. A loading() on a layout and another on the route below it (100 ms network delay)
| Loader takes | Fallbacks shown | Complete |
|---|---|---|
| 280 ms | outer | 412 |
| 320 ms | outer, then inner | 712 |
Why
- React does not replace a fallback sooner than 300 ms after the last fallback was committed (
react-dom-client.production.js:12183-12187, React 19.3.0: a retry waits forglobalMostRecentFallbackTime + 300). - A cold navigation that mounts new segments is committed urgently on the response's first chunk, "so fallbacks stream like a first load and the click has visible feedback" (
src/browser/partial-update.ts:773). The skeleton is committed even when the rest of the response is a few milliseconds behind, and from then on the content waits out the 300 ms. - Under
transition()the router wraps the segment in<ViewTransition>(src/segment-system.tsx:523-561), and React holds a new view-transition commit until the running transition'sfinishedresolves (react-dom-client.production.js:12282-12294). When the loader is slower than the throttle, something retries the boundary at about 300 ms, the render suspends again, and React still runs a transition for that commit (about 280 ms). The content then waits for it. What triggers that retry is not identified yet: in another fixture the same route shape started two transitions in production and three in dev. - With two boundaries, the outer reveal at 300 ms finds the inner data missing, the inner fallback is committed and the 300 ms starts again.
What an app sees
- A route with
loading()and a loader faster than 300 ms always shows its skeleton for 300 ms. Removingloading()makes the same click complete up to 300 ms sooner. - A route with
transition(),loading()and a loader slower than about 320 ms shows its content about 250 ms after the data arrived. - Fully prefetched clicks are not affected (they commit complete). Cold clicks, hard-to-prefetch routes and anything that streams are.
Options, none decided
- Do not commit the skeleton at once. Commit a cold navigation inside a transition and fall back to the urgent commit only if the page is still incomplete after a short wait. Even a wait of a few tens of milliseconds removes the rows above where the data was already in the response. Cost: the first visual change comes later by that wait, with
useNavigation()as the feedback meanwhile. Prior art: TanStack Router waits before showing its pending component ("By default, TanStack Router will show a pending component for loaders that take longer than 1 second to resolve",defaultPendingMs) and then keeps it for a minimum time ("will show your pending component for at least 500ms",defaultPendingMinMs). - No view transition for a commit that leaves the fallback in place. Needs the trigger of the idle retry identified first; then either the boundary does not animate that commit or the retry is avoided.
- Nested
loading(): reveal the outer boundary together with the inner one when the inner data is close, instead of committing a second fallback.
This changes what every route with loading() does on a cold click, so it needs a design and a decision on the default before any code.
Related
prefetch: false (in review) completes a prefetched page with a second request and times its reveal to match what a plain click does today, including the 300 ms above. If the plain click's schedule changes here, that code and its parity tests must follow.
Activity
ivogt commentedon Oct 7, 2026
Two more measurements on
main(0b498e15, production; dev within 10 ms), same method.A route with
loading()and no loader at all pays the same 300 ms. The handler returns at once and the response is complete a few milliseconds after the click:loading()and no loaderloading()and no loader, child routeato childbunder that layout (layout already on screen)So the cost does not need a loader, only a boundary that is new to the page and a commit on the first chunk. It is the same after other such boundaries have rendered, so it is not a first-read effect of the shared empty loader promise.
An app-placed
<ViewTransition>with notransition()on the route: a plain click shows the skeleton for about 300 ms and starts its view transition when the content replaces it (two transitions with a 500 ms loader when the boundary is in a layout: 319 to 595 and 634 to 929, complete at 646). A fully prefetched click starts one transition at the click and is complete at about 30 ms. The prefetched click starts no transition the plain click lacks.ivogt commentedon Oct 8, 2026
Status (2026-10-08): the trigger of the idle middle view transition is now being investigated on
main(local experiment, nothing pushed): one timeline of every update and commit for the 400 ms cell, the update that starts the idle transition named with its source, then three candidates measured: removing or reshaping that trigger, not animating a commit that leaves the fallback in place, and a short wait before the first commit (option 1 above). Results will be posted here.Related work is tracked in #1079 (a boundary handed a promise React has not read; the guard that keeps it from coming back). Draft #1080 removes the "no loader at all" row above in a production build.
ivogt commentedon Oct 8, 2026
The trigger of the idle view transition is identified, and four ways to remove the delay are measured (local experiment branch, a draft PR with the fixtures and candidates follows). No router update is involved: it is React committing its own Suspense retry.
Timeline of the 400 ms cell
Dev, instrumented react-dom dev build, ms after the click, all measured.
navigation).RouteContentWrapper,src/route-content-wrapper.tsx:107).src/use-loader.tsx:220). It completes with the fallback still in place, and its commit is throttled to 300 ms after the fallback.<ViewTransition>is on its path, so React starts transition 2. Nothing in the DOM changes. React cancels its groups, but the browser's default 250 ms animation keeps running until 640.With a 100 or 280 ms loader the loader resolves while the retry's commit is still throttled, so that commit is the reveal and there is no idle one.
Production is narrower than the table at the top suggests. The idle transition needs the client reference to be still loading in that render. With the route's client component module already loaded, a production build starts 2 transitions and completes at 416 ms (5 of 5). The numbers above were taken on a fresh page per click, which is the first navigation to that module. In dev every navigation has it, because the dev server tags client references per render. Two loaders under one
loading()do not depend on that: the retry after the first loader is the same kind of commit (fallback stays in place).Candidates
Complete, median ms, production (dev within 16 ms), 5 runs per cell, ranges within 48 ms of the median, load average 3 to 11.
<Suspense>main<ViewTransition update="none">What each changes besides the delay:
<Suspense>is not covered.same-route-nav.test.ts:230failed more often with it in dev under load (10 of 14 against 7 of 21 with the candidates off; that test is a known race onmain), so it is not cleared.main. It wrapsdocument.startViewTransitionand reads the animations React uses to cancel groups; neither is a contract. Suites that pin transitions and held navigations: dev 89 passed, production 63 passed.Routes a fix should not touch (
loading()withouttransition(),transition()withoutloading()) stay within 9 ms under a, b1, b2 and d.Not determined
ivogt commentedon Oct 8, 2026
The fixtures, recorders and the four candidates behind a switch are in draft #1084 (a measurement branch, not for merge as it is). Its bundle guard fails as expected: with all four candidates in the client build the router chunk is 48,449 B gzip against the 48,128 B limit; the per-candidate cost is not measured yet.
ivogt commentedon Oct 8, 2026
Two more facts for choosing a candidate.
Client bytes of each candidate alone (
origin/mainplus that candidate, cloudflare-basic client router chunk, gzip; limit 48,128,main47,488):<ViewTransition update="none">All fit on their own; typecheck passes on both unconditional variants.
Newer React does not fix it. Checked 19.3.0,
canary(19.3.0-canary-d75b0697-20261006) andexperimental(0.0.0-experimental-d75b0697-20261006). When every group of a view transition is cancelled, React applies zero-duration animations to each group, to::view-transition-group(root)and to::view-transition(19.3.0react-dom-client.production.js:11011-11054), and still callsdocument.startViewTransition(:15733); itsreadyhandler calls noskipTransition().skipTransition()is only called when more work is flushed (:13223).startViewTransitionis textually identical in canary. No issue or PR about it found in React's repository.So candidate d would stay in place until React changes this, and costs 341 B of the 640 B left under the limit; b1 costs 44 B and relies on nothing internal, but a streamed reveal under a router boundary no longer animates.
ivogt commentedon Oct 8, 2026
One more measurement for the choice between b1 and d, on a page built for the maintainer to try (production, route with
transition(),loading()and two loaders of 100 and 400 ms under it, read by two client components): main 3 view transitions, content at 649 to 681 ms; b1 1 transition, 407 to 412 ms; d 3 transitions, 664 to 711 ms: d does not help there. On that page React animates the route's group in the middle transition (a CSS animation on::view-transition-group(_t_0_)) instead of cancelling it, so d's check for a transition React cancelled entirely does not match, while nothing new is on screen. On the measurement route with loaders of 400 and 700 ms, d still ends the middle transitions (content 722 against 922 ms). So d covers only some shapes; b1 covered every shape measured.ivogt commentedon Oct 8, 2026
b1 is built (local branch
fix/vt-idle-transition) and is on hold. In production it does what it should: a click starts one view transition instead of three, and the content lands at 413 to 419 ms against 724 and 743 ms onmain. In dev, an action result or arouter.refresh()that stays on the same route no longer animates. Onmainit does, ande2e/transition-when-browser.test.ts:255fails in dev with b1.Cause, measured with a patched react-dom dev bundle:
claimQueuedTransitionTypes,react-dom-client.development.js:6015, called at:19546).use-navigation.ts:94,use-handle.ts:125,use-link-status.ts:125,use-action.ts:194)."action"type. The router's tree commit (lane 256) then has no type, so b1 gives it the class"none".b1 depends on the type reaching the router's commit, so the maintainer chose to fix this at its source: the hook updates commit together with the router's tree. That also satisfies the one-commit-per-navigation rule in #1079. A design pass is running (measure every commit per navigation and action in both modes, choose how the hooks join the router's commit, write the red tests). b1 waits for it.
ivogt commentedon Oct 11, 2026
Decision (2026-10-11): all three parts are accepted as React's behaviour and documented, with escape hatches. There is no router change, and the #1084 candidates won't be built.
Parts 1 and 3 (a
loading()skeleton held up to 300 ms on a cold click; a nestedloading()showing a second fallback): this is React's own 300 ms Suspense throttle. Holding the click so the skeleton shows later (option 1), or revealing the outer boundary together with the inner one (option 3), would trade an early skeleton for a wait. We prefer the early skeleton.Part 2 (the idle view transition under
transition()+loading()):loading()fallback in place is eligible for a view transition, so React callsdocument.startViewTransitionthrough the router's<ViewTransition>. React cancels its groups, but the browser's default animation still runs (265 to 285 ms fromreadytofinished). The loader's reveal waits until it finishes.loading()whose last lands later than about 320 ms.Measured on
main(a7a211f) in Chromium, 5 fresh pages per cell. Each cell isdocument.startViewTransitioncalls / ms until the 400 ms loader's value is on screen. Each cell gives two results: one 400 ms loader, then loaders of 100 and 400 ms.transition(){ when: () => false }{ viewTransition: false }Both escape hatches remove it:
viewTransition: falseplaces no router boundary, and the commit is still held.whenthat returns false commits urgently, with every router<ViewTransition>class set to"none". The skeleton streams and nothing animates.An app-placed
<ViewTransition>around a layout's outlet still starts the idle transition under either escape hatch (2 to 3 calls, 618 to 665 ms).update="none"on that boundary removes it (0 calls, 407 to 410 ms). That boundary is the app's to configure.The docs (the view-transitions skill and guide) and a dev + production test per app that counts view transitions will come in a PR that closes this issue.
docs(router): a transition() route with loading() can run a view tran…