Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
f22b89a
feat(react-router): publish router state as concurrent render frames
matclayton Aug 28, 2026
63793a3
test(e2e): assert view transitions actually run
matclayton Aug 28, 2026
efb0245
fix(react-router): preserve selector-level subscriptions on the frame…
matclayton Aug 28, 2026
e26610b
test: cover the render-frame invariants, and fix a hooks violation
matclayton Aug 28, 2026
4dfe0a7
fix: stop the frame protocol from changing shared StartTransitionFn
matclayton Aug 28, 2026
11d26a7
fix(react-router): scope frame reads so selectors and correctness bot…
matclayton Aug 28, 2026
a1bc7d4
fix(react-router): compare notifications against the committed selection
matclayton Aug 29, 2026
3f4f8f6
fix(react-router): commit selector config, and keep navigation progre…
matclayton Aug 29, 2026
47c5646
fix(react-router): keep a staged frame inside the render presenting it
claude Aug 29, 2026
37df8a4
fix(react-router): re-read on subscribing so a commit-phase publicati…
claude Aug 29, 2026
0205453
fix(router-core): answer explicit pending matches from the head
claude Aug 29, 2026
dbdf441
fix(react-router): only offer a staged frame from inside the transition
claude Aug 29, 2026
7848132
fix(react-router): key the frame owner by router identity
claude Aug 29, 2026
a3d5807
fix(react-router): tolerate a frame that drops the Outlet's own route
matclayton Sep 3, 2026
f369204
fix: answer presented matches and absent routes from the right public…
claude Sep 9, 2026
3135877
test(react-router): run the view-transition fixture on the frame path
claude Sep 9, 2026
0608e9b
fix(react-router): take progress from the head when a frame commits
claude Sep 9, 2026
9af6f37
fix: match a presented frame against its own location, and meet AGENT…
claude Sep 9, 2026
dd7cdda
chore: drop generated route-tree churn from the diff
claude Sep 9, 2026
479e67a
fix: keep hook order fixed, and keep a throwing selector out of the n…
claude Sep 9, 2026
4eba1b4
fix: navigate from the location a link's href was built from
claude Sep 9, 2026
e22b3fc
fix: freeze the frame-path decision per component
claude Sep 9, 2026
cc08459
fix: read a link's source location at click time, not in render
claude Sep 9, 2026
35331a6
fix: leave a structural-sharing cache untouched when probing an offer
claude Sep 9, 2026
125bdef
fix: keep a pending matcher following the head
claude Sep 9, 2026
70d36fd
fix: tag a queued render frame with the router that produced it
claude Sep 9, 2026
1e6d280
fix: key a consumer's presented frame by its scope
claude Sep 9, 2026
262c61e
fix: stop hydration remounting the route tree
claude Sep 10, 2026
e898b9e
docs: say that replacing a router keeps the path it mounted with
claude Sep 10, 2026
2755585
fix: publish a structural-sharing cache only once the render commits
claude Sep 10, 2026
66de605
fix: resolve an imperative navigation from the visible route
claude Sep 10, 2026
afd43d8
test: pin what a mount-effect navigation resolves against
claude Sep 10, 2026
07d4f0d
test: pin InnerWrap reading the committed route
claude Sep 10, 2026
1542bbd
test: assert hydration reports no recoverable errors
claude Sep 10, 2026
c1c3661
fix: queue a render frame per router, not one slot with a tag
claude Sep 10, 2026
b0b77ad
fix: own routers weakly, and keep the frame queue to one entry
claude Sep 10, 2026
e466ff9
fix: take the frame-path decision from the provider's owner
claude Sep 10, 2026
a4b8383
fix: keep the frame path a mounted component froze
claude Sep 10, 2026
5b1e5ff
fix: seed a new reader from the tree's frame path, not the owner's
claude Sep 10, 2026
a5eb269
fix: freeze a provider's frame path from the option, not the owner
claude Sep 10, 2026
76239a3
fix(solid): avoid repeated hydration scans and script execution (#8270)
LadyBluenotes Sep 10, 2026
8c9066e
fix: let a remounted tree adopt the frame still in flight
claude Sep 10, 2026
b6cf7b6
fix: resolve canGoBack from the browser history, not the frame
claude Sep 10, 2026
7f237d5
fix: adopt an in-flight frame on a router this tree returns to
claude Sep 10, 2026
ac22387
fix: publish a tree's frame-path decision on both arms
claude Sep 10, 2026
369156a
docs: say what frameId identifies, and what it does not
claude Sep 10, 2026
1fa7d28
fix: decide the queued frame once, rather than adopting then pruning
claude Sep 10, 2026
a7c8520
fix: drop a staged frame the head has already left, and advance frame…
claude Sep 10, 2026
103bedf
fix: compare the history entry too when dropping a superseded frame
claude Sep 10, 2026
e2ef78a
fix: do not adopt a staged frame the head has already left
claude Sep 10, 2026
f2d49e6
fix: do not seed a fresh reader from a staged frame the head has left
claude Sep 10, 2026
6128277
fix: revalidate the head at the acknowledgement boundary
claude Sep 10, 2026
45f3bf2
test: pin same-location refresh parity with the store path
claude Sep 10, 2026
b1c9219
perf(solid-router): skip client setup for all server links (#8316)
Sheraff Sep 10, 2026
69c07c4
test(router-core): cover search-derived context boundaries (#8334)
Sheraff Sep 10, 2026
3b27db6
docs: remove duplicate Router and Start page headings (#8339)
KevinVandy Sep 10, 2026
130992b
test(start): cover transformed script URL escaping in SSR (#8336)
Sheraff Sep 10, 2026
d914b08
fix(start-server-core): correctly iterate response headers (#8340)
Sheraff Sep 10, 2026
1eb8b03
chore: update nx to 23.2.0 (#8341)
Sheraff Sep 10, 2026
0a89977
fix(nx): remove deprecated self dependencies from Playwright plugin (…
Sheraff Sep 10, 2026
0ba4e9f
fix(nx): cache benchmark checks and build measurements (#8343)
Sheraff Sep 10, 2026
8c43c71
fix: upgrade TanStack Store to latest (#7824)
crutchcorn Sep 10, 2026
6494e75
ci: Version Packages (#8330)
github-actions[bot] Sep 10, 2026
ed86315
Merge TanStack Router main at 6494e753
claude Sep 10, 2026
dfb7a43
fix: stage no frame when the publication is re-entered
claude Sep 10, 2026
fb74dfe
fix: clear inherited frame ownership on the store path
claude Sep 10, 2026
ab5c693
fix: seed a fresh owner from a coherent publication
claude Sep 10, 2026
a911dfa
refactor: drop a dead clause from the root Suspense decision
claude Sep 10, 2026
1e7f755
fix: compare the history entry when seeding an owner
claude Sep 11, 2026
4eb18a9
fix: pair the seeded matches with the location they belong to
claude Sep 11, 2026
1caec35
fix: resync a cached owner before a tree reads it
claude Sep 11, 2026
79beee1
fix: seed an owner from the committed publication, not from the head
claude Sep 11, 2026
c1ab20a
fix: publish progress when the frame id has not moved
claude Sep 11, 2026
43afe0a
fix: seed from the acknowledged publication, and settle a foreign ack…
claude Sep 11, 2026
d826bb4
fix: drop a superseded pending frame in resync, and renormalize on a …
claude Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
10 changes: 10 additions & 0 deletions .changeset/concurrent-router-render-frames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/react-router': minor
'@tanstack/router-core': minor
---

Add an experimental `experimental_concurrentRenderFrames` router option that publishes router state to React as one immutable render frame per navigation.

React's `<ViewTransition>` and other transition-only behaviour never engage across a navigation today, because router state reaches components through `useSyncExternalStore`, which React schedules at a synchronous lane from the store's own subscription callback β€” after the `startTransition` scope has exited. When enabled, the React adapter owns the committed frame in state, stages a successor inside the transition, and acknowledges the exact rendered frame, so an interrupted or superseded navigation cannot settle a newer one.

Default off; with the option unset, the existing granular store subscriptions and selector behaviour are unchanged.
4 changes: 2 additions & 2 deletions .github/workflows/bundle-size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main

- name: Measure Bundle Size
run: pnpm nx run @benchmarks/bundle-size:build --outputStyle=stream --skipRemoteCache
run: pnpm benchmark:bundle-size --outputStyle=stream --skipRemoteCache

- name: Capture Benchmark Outputs
id: capture
Expand Down Expand Up @@ -127,7 +127,7 @@ jobs:
uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main

- name: Measure Bundle Size
run: pnpm nx run @benchmarks/bundle-size:build --outputStyle=stream --skipRemoteCache
run: pnpm benchmark:bundle-size --outputStyle=stream --skipRemoteCache
- name: Publish Benchmark Dashboard
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
with:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ nx-cloud.env
.nx/workspace-data
.nx/polygraph
.nx/self-healing
.nx/migrate-runs

gpt/db.json

Expand Down
5 changes: 4 additions & 1 deletion benchmarks/bundle-size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ pnpm benchmark:bundle-size:run --scenario react-router.minimal,react-router.full
```

The runner calls `measure.mjs`, which builds the selected package projects through Nx. Full runs build the package projects for all scenarios.
The existing `pnpm benchmark:bundle-size` command keeps its CI build graph unchanged.
`pnpm benchmark:bundle-size` uses the Nx build cache for the full benchmark, then generates `current.json` with the current commit, branch, dirty status, and report timestamp. CI uses this command as well.

Nx caches the bundles, `measurements.json`, and `benchmark-action.json`. Git metadata does not affect the build cache. The report preserves the original `measuredAt` and build duration when measurements come from cache; `generatedAt` records when the report was generated. Running `pnpm nx run @benchmarks/bundle-size:build` directly produces only the cacheable outputs; run `node scripts/benchmarks/bundle-size/report.mjs` afterward to refresh `current.json`.

If the required packages are already built and unchanged, skip that step:

Expand All @@ -50,6 +52,7 @@ pnpm benchmark:bundle-size:run --scenario react-router.minimal --skip-package-bu

This writes:

- `benchmarks/bundle-size/results/measurements.json`
- `benchmarks/bundle-size/results/current.json`
- `benchmarks/bundle-size/results/benchmark-action.json`
- `benchmarks/bundle-size/results/measure.log`
Expand Down
20 changes: 17 additions & 3 deletions benchmarks/bundle-size/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,27 @@
"private": true,
"type": "module",
"scripts": {
"build": "node ../../scripts/benchmarks/bundle-size/measure.mjs --skip-package-builds",
"test:unit": "node --test ../../scripts/benchmarks/bundle-size/pr-report.test.mjs ../../scripts/benchmarks/bundle-size/run.test.mjs"
"build": "node ../../scripts/benchmarks/bundle-size/measure.mjs --skip-package-builds --measurements-only",
"test:unit": "node --test ../../scripts/benchmarks/bundle-size/pr-report.test.mjs ../../scripts/benchmarks/bundle-size/run.test.mjs ../../scripts/benchmarks/bundle-size/report.test.mjs ../../scripts/benchmarks/bundle-size/build.test.mjs"
},
"nx": {
"targets": {
"build": {
"cache": false,
"inputs": [
"buildProduction",
"^buildProduction",
"dependentTaskOutputs",
"{workspaceRoot}/scripts/benchmarks/bundle-size/measure.mjs",
"{workspaceRoot}/scripts/benchmarks/bundle-size/report.mjs",
{
"runtime": "node --version"
}
],
"outputs": [
"{projectRoot}/dist",
"{projectRoot}/results/measurements.json",
"{projectRoot}/results/benchmark-action.json"
],
"dependsOn": [
{
"projects": [
Expand Down
275 changes: 142 additions & 133 deletions benchmarks/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { defineComponent } from 'vue'
import {
Asset,
Await,
Expand Down Expand Up @@ -45,143 +46,151 @@ import {
useTags,
} from '@tanstack/vue-router'

export const Route = createRootRoute({
component: RootComponent,
})
const RootComponent = defineComponent({
setup() {
const router = useRouter()
const [awaited] = useAwaited({ promise: Promise.resolve('ready') })
const linkProps = useLinkProps({ to: '/' } as any)
const matchRoute = useMatchRoute()
const matches = useMatches()
const parentMatches = useParentMatches()
const childMatches = useChildMatches()
const match = useMatch({ strict: false, shouldThrow: false } as any)
const loaderDeps = useLoaderDeps({ strict: false } as any)
const loaderData = useLoaderData({ strict: false } as any)
const params = useParams({ strict: false } as any)
const search = useSearch({ strict: false } as any)
const routeContext = useRouteContext({ strict: false } as any)
const routerState = useRouterState({
select: (state) => state.status,
} as any)
const location = useLocation()
const canGoBack = useCanGoBack()
const navigate = useNavigate()
const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' })
const tags = useTags()

function RootComponent() {
const router = useRouter()
const [awaited] = useAwaited({ promise: Promise.resolve('ready') })
const linkProps = useLinkProps({ to: '/' } as any)
const matchRoute = useMatchRoute()
const matches = useMatches()
const parentMatches = useParentMatches()
const childMatches = useChildMatches()
const match = useMatch({ strict: false, shouldThrow: false } as any)
const loaderDeps = useLoaderDeps({ strict: false } as any)
const loaderData = useLoaderData({ strict: false } as any)
const params = useParams({ strict: false } as any)
const search = useSearch({ strict: false } as any)
const routeContext = useRouteContext({ strict: false } as any)
const routerState = useRouterState({ select: (state) => state.status } as any)
const location = useLocation()
const canGoBack = useCanGoBack()
const navigate = useNavigate()
const scrollEntry = useElementScrollRestoration({ id: 'root-scroll' })
const tags = useTags()
useBlocker({
shouldBlockFn: () => false,
disabled: true,
withResolver: false,
})

useBlocker({
shouldBlockFn: () => false,
disabled: true,
withResolver: false,
})
const linkFactoryResult = linkOptions({ to: '/' } as any)
const routeMatchResult = matchRoute({ to: '/' } as any)
const SvgLink = createLink('svg')

const linkFactoryResult = linkOptions({ to: '/' } as any)
const routeMatchResult = matchRoute({ to: '/' } as any)
const SvgLink = createLink('svg')
const hooksAndComponents = [
useAwaited,
useLinkProps,
useMatchRoute,
useMatches,
useParentMatches,
useChildMatches,
useMatch,
useLoaderDeps,
useLoaderData,
useBlocker,
useNavigate,
useParams,
useSearch,
useRouteContext,
useRouter,
useRouterState,
useLocation,
useCanGoBack,
useElementScrollRestoration,
useTags,
Await,
CatchBoundary,
CatchNotFound,
ClientOnly,
DefaultGlobalNotFound,
ErrorComponent,
Link,
Match,
MatchRoute,
Matches,
Navigate,
Outlet,
RouterContextProvider,
ScrollRestoration,
Block,
ScriptOnce,
Asset,
HeadContent,
Scripts,
Body,
Html,
]

const hooksAndComponents = [
useAwaited,
useLinkProps,
useMatchRoute,
useMatches,
useParentMatches,
useChildMatches,
useMatch,
useLoaderDeps,
useLoaderData,
useBlocker,
useNavigate,
useParams,
useSearch,
useRouteContext,
useRouter,
useRouterState,
useLocation,
useCanGoBack,
useElementScrollRestoration,
useTags,
Await,
CatchBoundary,
CatchNotFound,
ClientOnly,
DefaultGlobalNotFound,
ErrorComponent,
Link,
Match,
MatchRoute,
Matches,
Navigate,
Outlet,
RouterContextProvider,
ScrollRestoration,
Block,
ScriptOnce,
Asset,
HeadContent,
Scripts,
Body,
Html,
]
;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = {
hooksAndComponents,
}

;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = {
hooksAndComponents,
}
return () => {
void awaited
void linkFactoryResult
void matches.value
void parentMatches.value
void childMatches.value
void match.value
void loaderDeps.value
void loaderData.value
void params.value
void search.value
void routeContext.value
void routerState.value
void location.value
void canGoBack.value
void navigate
void scrollEntry
void tags()
void routeMatchResult.value

void awaited
void linkFactoryResult
void matches.value
void parentMatches.value
void childMatches.value
void match.value
void loaderDeps.value
void loaderData.value
void params.value
void search.value
void routeContext.value
void routerState.value
void location.value
void canGoBack.value
void navigate
void scrollEntry
void tags()
void routeMatchResult.value
return (
<>
<HeadContent />
<ScriptOnce>{'window.__tsr_bundle_size = true'}</ScriptOnce>
<Asset
tag="meta"
attrs={{ name: 'bundle-size', content: 'vue-router-full' }}
/>
<Link {...(linkProps as any)}>home</Link>
<SvgLink to="/" aria-label="svg-home">
<circle cx="8" cy="8" r="7" />
</SvgLink>
<MatchRoute to="/">
{() => <span data-test="match-route" />}
</MatchRoute>
<ClientOnly fallback={<span data-test="client-only-fallback" />}>
<span data-test="client-only" />
</ClientOnly>
<Await
promise={Promise.resolve('done')}
children={() => <span data-test="await" />}
/>
<Block shouldBlockFn={() => false} disabled withResolver={false}>
{() => <span data-test="block" />}
</Block>
<CatchNotFound fallback={() => <DefaultGlobalNotFound />}>
<span data-test="catch-not-found" />
</CatchNotFound>
<RouterContextProvider router={router}>
<span data-test="nested-router-context" />
</RouterContextProvider>
<ScrollRestoration />
<Outlet />
<Scripts />
<div data-test="full-root">
<div>hello world</div>
</div>
</>
)
}
},
})

return (
<>
<HeadContent />
<ScriptOnce>{'window.__tsr_bundle_size = true'}</ScriptOnce>
<Asset
tag="meta"
attrs={{ name: 'bundle-size', content: 'vue-router-full' }}
/>
<Link {...(linkProps as any)}>home</Link>
<SvgLink to="/" aria-label="svg-home">
<circle cx="8" cy="8" r="7" />
</SvgLink>
<MatchRoute to="/">{() => <span data-test="match-route" />}</MatchRoute>
<ClientOnly fallback={<span data-test="client-only-fallback" />}>
<span data-test="client-only" />
</ClientOnly>
<Await
promise={Promise.resolve('done')}
children={() => <span data-test="await" />}
/>
<Block shouldBlockFn={() => false} disabled withResolver={false}>
{() => <span data-test="block" />}
</Block>
<CatchNotFound fallback={() => <DefaultGlobalNotFound />}>
<span data-test="catch-not-found" />
</CatchNotFound>
<RouterContextProvider router={router}>
<span data-test="nested-router-context" />
</RouterContextProvider>
<ScrollRestoration />
<Outlet />
<Scripts />
<div data-test="full-root">
<div>hello world</div>
</div>
</>
)
}
export const Route = createRootRoute({
component: RootComponent,
})
Loading