diff --git a/.changeset/concurrent-router-render-frames.md b/.changeset/concurrent-router-render-frames.md new file mode 100644 index 00000000000..9d3a7c0a0b2 --- /dev/null +++ b/.changeset/concurrent-router-render-frames.md @@ -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 `` 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. diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index 23c354e06e6..5d628ff7682 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -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 @@ -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: diff --git a/.gitignore b/.gitignore index 1699592784a..70028b28607 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ nx-cloud.env .nx/workspace-data .nx/polygraph .nx/self-healing +.nx/migrate-runs gpt/db.json diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index c845382fdbc..e5986f106cc 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -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: @@ -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` diff --git a/benchmarks/bundle-size/package.json b/benchmarks/bundle-size/package.json index 9decbae9da8..3705df05fc0 100644 --- a/benchmarks/bundle-size/package.json +++ b/benchmarks/bundle-size/package.json @@ -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": [ diff --git a/benchmarks/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx b/benchmarks/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx index d696063083e..0ff40aebfc5 100644 --- a/benchmarks/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx +++ b/benchmarks/bundle-size/scenarios/vue-router-full/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { defineComponent } from 'vue' import { Asset, Await, @@ -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 ( + <> + + {'window.__tsr_bundle_size = true'} + + home + + + + + {() => } + + }> + + + } + /> + false} disabled withResolver={false}> + {() => } + + }> + + + + + + + + +
+
hello world
+
+ + ) + } + }, +}) - return ( - <> - - {'window.__tsr_bundle_size = true'} - - home - - - - {() => } - }> - - - } - /> - false} disabled withResolver={false}> - {() => } - - }> - - - - - - - - -
-
hello world
-
- - ) -} +export const Route = createRootRoute({ + component: RootComponent, +}) diff --git a/benchmarks/bundle-size/scenarios/vue-start-full/src/routes/__root.tsx b/benchmarks/bundle-size/scenarios/vue-start-full/src/routes/__root.tsx index aa0539fa1a7..8e8870a2ce7 100644 --- a/benchmarks/bundle-size/scenarios/vue-start-full/src/routes/__root.tsx +++ b/benchmarks/bundle-size/scenarios/vue-start-full/src/routes/__root.tsx @@ -1,3 +1,4 @@ +import { defineComponent } from 'vue' import { Asset, Await, @@ -68,151 +69,159 @@ const helloServerFn = createServerFn({ method: 'GET' }) return 'hello from server fn' }) -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() + const invokeServerFn = useServerFn(helloServerFn) -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() - const invokeServerFn = useServerFn(helloServerFn) + 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 startSurface = [createMiddleware, createServerFn, useServerFn] + 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 startSurface = [createMiddleware, createServerFn, useServerFn] - 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, + startSurface, + } - ;(globalThis as any).__TANSTACK_BUNDLE_SIZE_KEEP__ = { - hooksAndComponents, - startSurface, - } + 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 invokeServerFn - 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 invokeServerFn + return ( + + + + + + {'window.__tsr_bundle_size = true'} + + home + + + + + {() => } + + }> + + + } + /> + false} disabled withResolver={false}> + {() => } + + }> + + + + + + + + +
+
hello world
+
+ + + ) + } + }, +}) - return ( - - - - - - {'window.__tsr_bundle_size = true'} - - home - - - - {() => } - }> - - - } - /> - false} disabled withResolver={false}> - {() => } - - }> - - - - - - - - -
-
hello world
-
- - - ) -} +export const Route = createRootRoute({ + component: RootComponent, +}) diff --git a/benchmarks/client-nav/package.json b/benchmarks/client-nav/package.json index 1b42e8fc874..84f0dc9807d 100644 --- a/benchmarks/client-nav/package.json +++ b/benchmarks/client-nav/package.json @@ -178,7 +178,13 @@ ] }, "test:types": { - "cache": false, + "inputs": [ + "default", + "^production", + "{workspaceRoot}/eslint.config.js", + "{workspaceRoot}/benchmarks/cpu-simulation.ts", + "dependentTaskOutputs" + ], "dependsOn": [ "^build", { diff --git a/benchmarks/memory/client/scenarios/interrupted-navigations/vue/src/routes/fast.$id.tsx b/benchmarks/memory/client/scenarios/interrupted-navigations/vue/src/routes/fast.$id.tsx index 3bd820bd074..f6dc9ac3bdd 100644 --- a/benchmarks/memory/client/scenarios/interrupted-navigations/vue/src/routes/fast.$id.tsx +++ b/benchmarks/memory/client/scenarios/interrupted-navigations/vue/src/routes/fast.$id.tsx @@ -1,6 +1,19 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { fixedTimestamp } from '../../../slow-loaders' +const FastComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+ {`${data.value.kind}:${data.value.id}:${data.value.ts}`} +
+ ) + }, +}) + export const Route = createFileRoute('/fast/$id')({ loader: ({ params }: { params: { id: string } }) => ({ id: params.id, @@ -9,13 +22,3 @@ export const Route = createFileRoute('/fast/$id')({ }), component: FastComponent, }) - -function FastComponent() { - const data = Route.useLoaderData() - - return ( -
- {`${data.value.kind}:${data.value.id}:${data.value.ts}`} -
- ) -} diff --git a/benchmarks/memory/client/scenarios/interrupted-navigations/vue/src/routes/slow.$id.tsx b/benchmarks/memory/client/scenarios/interrupted-navigations/vue/src/routes/slow.$id.tsx index 68885fb918b..3b5bb75ede1 100644 --- a/benchmarks/memory/client/scenarios/interrupted-navigations/vue/src/routes/slow.$id.tsx +++ b/benchmarks/memory/client/scenarios/interrupted-navigations/vue/src/routes/slow.$id.tsx @@ -1,6 +1,19 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { getSlowLoaderDeferred } from '../../../slow-loaders' +const SlowComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+ {`${data.value.kind}:${data.value.id}:${data.value.ts}`} +
+ ) + }, +}) + export const Route = createFileRoute('/slow/$id')({ loader: async ({ params }: { params: { id: string } }) => { const deferred = getSlowLoaderDeferred(params.id) @@ -9,13 +22,3 @@ export const Route = createFileRoute('/slow/$id')({ }, component: SlowComponent, }) - -function SlowComponent() { - const data = Route.useLoaderData() - - return ( -
- {`${data.value.kind}:${data.value.id}:${data.value.ts}`} -
- ) -} diff --git a/benchmarks/memory/client/scenarios/loader-data-retention/vue/src/routes/page.$id.tsx b/benchmarks/memory/client/scenarios/loader-data-retention/vue/src/routes/page.$id.tsx index 9f4b3c4470e..38f1d2614c8 100644 --- a/benchmarks/memory/client/scenarios/loader-data-retention/vue/src/routes/page.$id.tsx +++ b/benchmarks/memory/client/scenarios/loader-data-retention/vue/src/routes/page.$id.tsx @@ -1,22 +1,25 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { createLoaderData } from '../../../loader-data' +const PageComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+ {`${data.value.id}:${data.value.records.length}`} +
+ ) + }, +}) + export const Route = createFileRoute('/page/$id')({ loader: ({ params }: { params: { id: string } }) => createLoaderData(params.id), component: PageComponent, }) - -function PageComponent() { - const data = Route.useLoaderData() - - return ( -
- {`${data.value.id}:${data.value.records.length}`} -
- ) -} diff --git a/benchmarks/memory/client/scenarios/mount-unmount/vue/src/routes/a.tsx b/benchmarks/memory/client/scenarios/mount-unmount/vue/src/routes/a.tsx index cbcd543ce59..0dd650e1dd3 100644 --- a/benchmarks/memory/client/scenarios/mount-unmount/vue/src/routes/a.tsx +++ b/benchmarks/memory/client/scenarios/mount-unmount/vue/src/routes/a.tsx @@ -1,12 +1,15 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' +const AComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () =>
{data.value.id}
+ }, +}) + export const Route = createFileRoute('/a')({ loader: () => ({ id: 'a' }), component: AComponent, }) - -function AComponent() { - const data = Route.useLoaderData() - - return
{data.value.id}
-} diff --git a/benchmarks/memory/client/scenarios/navigation-churn/vue/src/routes/a.tsx b/benchmarks/memory/client/scenarios/navigation-churn/vue/src/routes/a.tsx index 183228e7484..c1a9cfb2445 100644 --- a/benchmarks/memory/client/scenarios/navigation-churn/vue/src/routes/a.tsx +++ b/benchmarks/memory/client/scenarios/navigation-churn/vue/src/routes/a.tsx @@ -1,16 +1,19 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' const fixedTimestamp = 1_700_000_000_000 +const AComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
{`${data.value.name}:${data.value.ts}`}
+ ) + }, +}) + export const Route = createFileRoute('/a')({ loader: () => ({ name: 'a', ts: fixedTimestamp }), component: AComponent, }) - -function AComponent() { - const data = Route.useLoaderData() - - return ( -
{`${data.value.name}:${data.value.ts}`}
- ) -} diff --git a/benchmarks/memory/client/scenarios/navigation-churn/vue/src/routes/b.tsx b/benchmarks/memory/client/scenarios/navigation-churn/vue/src/routes/b.tsx index 3a18ec97a97..3a20799f3b9 100644 --- a/benchmarks/memory/client/scenarios/navigation-churn/vue/src/routes/b.tsx +++ b/benchmarks/memory/client/scenarios/navigation-churn/vue/src/routes/b.tsx @@ -1,16 +1,19 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' const fixedTimestamp = 1_700_000_000_000 +const BComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
{`${data.value.name}:${data.value.ts}`}
+ ) + }, +}) + export const Route = createFileRoute('/b')({ loader: () => ({ name: 'b', ts: fixedTimestamp }), component: BComponent, }) - -function BComponent() { - const data = Route.useLoaderData() - - return ( -
{`${data.value.name}:${data.value.ts}`}
- ) -} diff --git a/benchmarks/memory/client/scenarios/preload-churn/vue/src/routes/items.$id.tsx b/benchmarks/memory/client/scenarios/preload-churn/vue/src/routes/items.$id.tsx index 2a292459268..08254e173e4 100644 --- a/benchmarks/memory/client/scenarios/preload-churn/vue/src/routes/items.$id.tsx +++ b/benchmarks/memory/client/scenarios/preload-churn/vue/src/routes/items.$id.tsx @@ -1,6 +1,19 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { createItemPayload, trackItemLoaderCall } from '../../../item-payload' +const ItemComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+ {`${data.value.id}:${data.value.byteLength}`} +
+ ) + }, +}) + export const Route = createFileRoute('/items/$id')({ loader: ({ params }: { params: { id: string } }) => { trackItemLoaderCall(params.id) @@ -8,13 +21,3 @@ export const Route = createFileRoute('/items/$id')({ }, component: ItemComponent, }) - -function ItemComponent() { - const data = Route.useLoaderData() - - return ( -
- {`${data.value.id}:${data.value.byteLength}`} -
- ) -} diff --git a/benchmarks/memory/client/scenarios/unique-location-churn/vue/src/routes/items.$id.tsx b/benchmarks/memory/client/scenarios/unique-location-churn/vue/src/routes/items.$id.tsx index 18543671427..e796ee14d1c 100644 --- a/benchmarks/memory/client/scenarios/unique-location-churn/vue/src/routes/items.$id.tsx +++ b/benchmarks/memory/client/scenarios/unique-location-churn/vue/src/routes/items.$id.tsx @@ -1,9 +1,22 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' type ItemSearch = { q: string } +const ItemComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
{`${data.value.id}:${data.value.q}:${data.value.checksum}`}
+ ) + }, +}) + export const Route = createFileRoute('/items/$id')({ validateSearch: (search: Record): ItemSearch => ({ q: typeof search.q === 'string' ? search.q : '', @@ -22,13 +35,3 @@ export const Route = createFileRoute('/items/$id')({ }), component: ItemComponent, }) - -function ItemComponent() { - const data = Route.useLoaderData() - - return ( -
{`${data.value.id}:${data.value.q}:${data.value.checksum}`}
- ) -} diff --git a/benchmarks/memory/server/scenarios/aborted-requests/vue/src/routes/stream.$id.tsx b/benchmarks/memory/server/scenarios/aborted-requests/vue/src/routes/stream.$id.tsx index 448355b1229..e41af03c164 100644 --- a/benchmarks/memory/server/scenarios/aborted-requests/vue/src/routes/stream.$id.tsx +++ b/benchmarks/memory/server/scenarios/aborted-requests/vue/src/routes/stream.$id.tsx @@ -1,12 +1,60 @@ import { createServerOnlyFn } from '@tanstack/vue-start' import { getRequest } from '@tanstack/vue-start/server' import { Await, createFileRoute } from '@tanstack/vue-router' -import { Suspense } from 'vue' +import { Suspense, defineComponent } from 'vue' import { makeDeferredRecords } from '../../../deferred-records' import type { DeferredRecord } from '../../../deferred-records' const getRequestSignal = createServerOnlyFn(() => getRequest().signal) +const StreamComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+

{data.value.eager}

+

loading-alpha

+

loading-beta

+ + {{ + default: () => ( + ) => ( +
    + {records.map((record) => ( +
  • {record.label}
  • + ))} +
+ )} + /> + ), + fallback: () => null, + }} +
+ + {{ + default: () => ( + ) => ( +
    + {records.map((record) => ( +
  • {record.label}
  • + ))} +
+ )} + /> + ), + fallback: () => null, + }} +
+
+ ) + }, +}) + export const Route = createFileRoute('/stream/$id')({ loader: ({ params }) => ({ eager: `eager-${params.id}`, @@ -15,49 +63,3 @@ export const Route = createFileRoute('/stream/$id')({ }), component: StreamComponent, }) - -function StreamComponent() { - const data = Route.useLoaderData() - - return ( -
-

{data.value.eager}

-

loading-alpha

-

loading-beta

- - {{ - default: () => ( - ) => ( -
    - {records.map((record) => ( -
  • {record.label}
  • - ))} -
- )} - /> - ), - fallback: () => null, - }} -
- - {{ - default: () => ( - ) => ( -
    - {records.map((record) => ( -
  • {record.label}
  • - ))} -
- )} - /> - ), - fallback: () => null, - }} -
-
- ) -} diff --git a/benchmarks/memory/server/scenarios/error-paths/vue/src/routes/target.$id.tsx b/benchmarks/memory/server/scenarios/error-paths/vue/src/routes/target.$id.tsx index 17ade6bf5b7..0bbf4a3dc65 100644 --- a/benchmarks/memory/server/scenarios/error-paths/vue/src/routes/target.$id.tsx +++ b/benchmarks/memory/server/scenarios/error-paths/vue/src/routes/target.$id.tsx @@ -1,11 +1,14 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' +const TargetComponent = defineComponent({ + setup() { + const params = Route.useParams() + + return () =>
{`target-${params.value.id}`}
+ }, +}) + export const Route = createFileRoute('/target/$id')({ component: TargetComponent, }) - -function TargetComponent() { - const params = Route.useParams() - - return
{`target-${params.value.id}`}
-} diff --git a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.l7.l8.tsx b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.l7.l8.tsx index bd079aa1a06..7f7a42d645c 100644 --- a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.l7.l8.tsx +++ b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.l7.l8.tsx @@ -1,28 +1,34 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { makeLargePageHead, makeLargePageLevelData, } from '../../../large-page-data' +const LevelEightComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => { + const first = data.value.records[0]! + + return ( +
+

{data.value.marker}

+

records: {data.value.records.length}

+
+

{first.name}

+

{first.id}

+

{first.description}

+
+
+ ) + } + }, +}) + export const Route = createFileRoute('/l1/l2/l3/l4/l5/l6/l7/l8')({ loader: () => makeLargePageLevelData(8, 0x5eed_1008), head: ({ loaderData }) => makeLargePageHead(loaderData), component: LevelEightComponent, }) - -function LevelEightComponent() { - const data = Route.useLoaderData() - const first = data.value.records[0]! - - return ( -
-

{data.value.marker}

-

records: {data.value.records.length}

-
-

{first.name}

-

{first.id}

-

{first.description}

-
-
- ) -} diff --git a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.l7.tsx b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.l7.tsx index 7f69f6f438a..a99c56e5291 100644 --- a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.l7.tsx +++ b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.l7.tsx @@ -1,29 +1,35 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLargePageHead, makeLargePageLevelData, } from '../../../large-page-data' +const LevelSevenComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => { + const first = data.value.records[0]! + + return ( +
+

{data.value.marker}

+

records: {data.value.records.length}

+
+

{first.name}

+

{first.id}

+

{first.description}

+
+ +
+ ) + } + }, +}) + export const Route = createFileRoute('/l1/l2/l3/l4/l5/l6/l7')({ loader: () => makeLargePageLevelData(7, 0x5eed_1007), head: ({ loaderData }) => makeLargePageHead(loaderData), component: LevelSevenComponent, }) - -function LevelSevenComponent() { - const data = Route.useLoaderData() - const first = data.value.records[0]! - - return ( -
-

{data.value.marker}

-

records: {data.value.records.length}

-
-

{first.name}

-

{first.id}

-

{first.description}

-
- -
- ) -} diff --git a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.tsx b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.tsx index 75a7c717e3f..3de3f03427c 100644 --- a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.tsx +++ b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.l6.tsx @@ -1,29 +1,35 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLargePageHead, makeLargePageLevelData, } from '../../../large-page-data' +const LevelSixComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => { + const first = data.value.records[0]! + + return ( +
+

{data.value.marker}

+

records: {data.value.records.length}

+
+

{first.name}

+

{first.id}

+

{first.description}

+
+ +
+ ) + } + }, +}) + export const Route = createFileRoute('/l1/l2/l3/l4/l5/l6')({ loader: () => makeLargePageLevelData(6, 0x5eed_1006), head: ({ loaderData }) => makeLargePageHead(loaderData), component: LevelSixComponent, }) - -function LevelSixComponent() { - const data = Route.useLoaderData() - const first = data.value.records[0]! - - return ( -
-

{data.value.marker}

-

records: {data.value.records.length}

-
-

{first.name}

-

{first.id}

-

{first.description}

-
- -
- ) -} diff --git a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.tsx b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.tsx index f390ae3db30..5d936f83b54 100644 --- a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.tsx +++ b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.l5.tsx @@ -1,29 +1,35 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLargePageHead, makeLargePageLevelData, } from '../../../large-page-data' +const LevelFiveComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => { + const first = data.value.records[0]! + + return ( +
+

{data.value.marker}

+

records: {data.value.records.length}

+
+

{first.name}

+

{first.id}

+

{first.description}

+
+ +
+ ) + } + }, +}) + export const Route = createFileRoute('/l1/l2/l3/l4/l5')({ loader: () => makeLargePageLevelData(5, 0x5eed_1005), head: ({ loaderData }) => makeLargePageHead(loaderData), component: LevelFiveComponent, }) - -function LevelFiveComponent() { - const data = Route.useLoaderData() - const first = data.value.records[0]! - - return ( -
-

{data.value.marker}

-

records: {data.value.records.length}

-
-

{first.name}

-

{first.id}

-

{first.description}

-
- -
- ) -} diff --git a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.tsx b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.tsx index 6b30bd50dc5..8e10331f2d5 100644 --- a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.tsx +++ b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.l4.tsx @@ -1,29 +1,35 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLargePageHead, makeLargePageLevelData, } from '../../../large-page-data' +const LevelFourComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => { + const first = data.value.records[0]! + + return ( +
+

{data.value.marker}

+

records: {data.value.records.length}

+
+

{first.name}

+

{first.id}

+

{first.description}

+
+ +
+ ) + } + }, +}) + export const Route = createFileRoute('/l1/l2/l3/l4')({ loader: () => makeLargePageLevelData(4, 0x5eed_1004), head: ({ loaderData }) => makeLargePageHead(loaderData), component: LevelFourComponent, }) - -function LevelFourComponent() { - const data = Route.useLoaderData() - const first = data.value.records[0]! - - return ( -
-

{data.value.marker}

-

records: {data.value.records.length}

-
-

{first.name}

-

{first.id}

-

{first.description}

-
- -
- ) -} diff --git a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.tsx b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.tsx index 6ec6f32fea4..d69529c50cd 100644 --- a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.tsx +++ b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.l3.tsx @@ -1,29 +1,35 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLargePageHead, makeLargePageLevelData, } from '../../../large-page-data' +const LevelThreeComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => { + const first = data.value.records[0]! + + return ( +
+

{data.value.marker}

+

records: {data.value.records.length}

+
+

{first.name}

+

{first.id}

+

{first.description}

+
+ +
+ ) + } + }, +}) + export const Route = createFileRoute('/l1/l2/l3')({ loader: () => makeLargePageLevelData(3, 0x5eed_1003), head: ({ loaderData }) => makeLargePageHead(loaderData), component: LevelThreeComponent, }) - -function LevelThreeComponent() { - const data = Route.useLoaderData() - const first = data.value.records[0]! - - return ( -
-

{data.value.marker}

-

records: {data.value.records.length}

-
-

{first.name}

-

{first.id}

-

{first.description}

-
- -
- ) -} diff --git a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.tsx b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.tsx index e5b4cf0de1d..d04bc7f8273 100644 --- a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.tsx +++ b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.l2.tsx @@ -1,29 +1,35 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLargePageHead, makeLargePageLevelData, } from '../../../large-page-data' +const LevelTwoComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => { + const first = data.value.records[0]! + + return ( +
+

{data.value.marker}

+

records: {data.value.records.length}

+
+

{first.name}

+

{first.id}

+

{first.description}

+
+ +
+ ) + } + }, +}) + export const Route = createFileRoute('/l1/l2')({ loader: () => makeLargePageLevelData(2, 0x5eed_1002), head: ({ loaderData }) => makeLargePageHead(loaderData), component: LevelTwoComponent, }) - -function LevelTwoComponent() { - const data = Route.useLoaderData() - const first = data.value.records[0]! - - return ( -
-

{data.value.marker}

-

records: {data.value.records.length}

-
-

{first.name}

-

{first.id}

-

{first.description}

-
- -
- ) -} diff --git a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.tsx b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.tsx index a29e465cd48..a95ae7f9b05 100644 --- a/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.tsx +++ b/benchmarks/memory/server/scenarios/peak-large-page/vue/src/routes/l1.tsx @@ -1,29 +1,35 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLargePageHead, makeLargePageLevelData, } from '../../../large-page-data' +const LevelOneComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => { + const first = data.value.records[0]! + + return ( +
+

{data.value.marker}

+

records: {data.value.records.length}

+
+

{first.name}

+

{first.id}

+

{first.description}

+
+ +
+ ) + } + }, +}) + export const Route = createFileRoute('/l1')({ loader: () => makeLargePageLevelData(1, 0x5eed_1001), head: ({ loaderData }) => makeLargePageHead(loaderData), component: LevelOneComponent, }) - -function LevelOneComponent() { - const data = Route.useLoaderData() - const first = data.value.records[0]! - - return ( -
-

{data.value.marker}

-

records: {data.value.records.length}

-
-

{first.name}

-

{first.id}

-

{first.description}

-
- -
- ) -} diff --git a/benchmarks/memory/server/scenarios/request-churn/vue/src/routes/items.$id.tsx b/benchmarks/memory/server/scenarios/request-churn/vue/src/routes/items.$id.tsx index a98d0365659..570561f2a89 100644 --- a/benchmarks/memory/server/scenarios/request-churn/vue/src/routes/items.$id.tsx +++ b/benchmarks/memory/server/scenarios/request-churn/vue/src/routes/items.$id.tsx @@ -1,3 +1,4 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' const itemIndexes = Array.from({ length: 5 }, (_, index) => index) @@ -6,6 +7,24 @@ type ItemSearch = { q: string } +const ItemComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+

{data.value.title}

+

{data.value.q}

+
    + {data.value.items.map((item) => ( +
  • {item.label}
  • + ))} +
+
+ ) + }, +}) + export const Route = createFileRoute('/items/$id')({ validateSearch: (search: Record): ItemSearch => ({ q: typeof search.q === 'string' ? search.q : '', @@ -22,19 +41,3 @@ export const Route = createFileRoute('/items/$id')({ }), component: ItemComponent, }) - -function ItemComponent() { - const data = Route.useLoaderData() - - return ( -
-

{data.value.title}

-

{data.value.q}

-
    - {data.value.items.map((item) => ( -
  • {item.label}
  • - ))} -
-
- ) -} diff --git a/benchmarks/memory/server/scenarios/serialization-payload/vue/src/routes/data.$id.tsx b/benchmarks/memory/server/scenarios/serialization-payload/vue/src/routes/data.$id.tsx index e6b4cfadfb5..37b1a590e4e 100644 --- a/benchmarks/memory/server/scenarios/serialization-payload/vue/src/routes/data.$id.tsx +++ b/benchmarks/memory/server/scenarios/serialization-payload/vue/src/routes/data.$id.tsx @@ -1,17 +1,20 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { makeSerializationPayload } from '../../../serialization-payload' +const DataComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+ Map size: {data.value.lookup.size} +
+ ) + }, +}) + export const Route = createFileRoute('/data/$id')({ loader: ({ params }) => makeSerializationPayload(params.id), component: DataComponent, }) - -function DataComponent() { - const data = Route.useLoaderData() - - return ( -
- Map size: {data.value.lookup.size} -
- ) -} diff --git a/benchmarks/memory/server/scenarios/streaming-peak/vue/src/routes/stream.$id.tsx b/benchmarks/memory/server/scenarios/streaming-peak/vue/src/routes/stream.$id.tsx index 0c71a8d3a04..750d1cb47da 100644 --- a/benchmarks/memory/server/scenarios/streaming-peak/vue/src/routes/stream.$id.tsx +++ b/benchmarks/memory/server/scenarios/streaming-peak/vue/src/routes/stream.$id.tsx @@ -1,5 +1,5 @@ import { Await, createFileRoute } from '@tanstack/vue-router' -import { Suspense } from 'vue' +import { Suspense, defineComponent } from 'vue' import { makeDeferredSectionPayload, type DeferredSectionPayload, @@ -7,6 +7,47 @@ import { const fallbackFlushTicks = 20 +const StreamComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => { + const deferredSections = [ + { index: 0, promise: data.value.deferred0 }, + { index: 1, promise: data.value.deferred1 }, + { index: 2, promise: data.value.deferred2 }, + { index: 3, promise: data.value.deferred3 }, + ] as const + + return ( +
+

{data.value.eager}

+ {deferredSections.map(({ index, promise }) => ( + <> +

+ streaming-peak-fallback-{index} +

+ + {{ + default: () => ( + ( + + )} + /> + ), + fallback: () => null, + }} + + + ))} +
+ ) + } + }, +}) + export const Route = createFileRoute('/stream/$id')({ loader: ({ params }) => ({ eager: `streaming-peak-eager-${params.id}`, @@ -48,42 +89,6 @@ function makeDeferredSection(id: string, sectionIndex: number) { ) } -function StreamComponent() { - const data = Route.useLoaderData() - const deferredSections = [ - { index: 0, promise: data.value.deferred0 }, - { index: 1, promise: data.value.deferred1 }, - { index: 2, promise: data.value.deferred2 }, - { index: 3, promise: data.value.deferred3 }, - ] as const - - return ( -
-

{data.value.eager}

- {deferredSections.map(({ index, promise }) => ( - <> -

- streaming-peak-fallback-{index} -

- - {{ - default: () => ( - ( - - )} - /> - ), - fallback: () => null, - }} - - - ))} -
- ) -} - function DeferredSection({ section }: { section: DeferredSectionPayload }) { const marker = `streaming-peak-deferred-${section.index}` diff --git a/benchmarks/ssr/scenarios/assets/vue/src/routes/a.$x.$y.tsx b/benchmarks/ssr/scenarios/assets/vue/src/routes/a.$x.$y.tsx index a20e292938b..a29fe59f225 100644 --- a/benchmarks/ssr/scenarios/assets/vue/src/routes/a.$x.$y.tsx +++ b/benchmarks/ssr/scenarios/assets/vue/src/routes/a.$x.$y.tsx @@ -1,6 +1,21 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import '../styles/assets-leaf.css' +const LeafComponent = defineComponent({ + setup() { + const params = Route.useParams() + + return () => ( +
+

+ assets-leaf-{params.value.x}-{params.value.y} +

+
+ ) + }, +}) + export const Route = createFileRoute('/a/$x/$y')({ head: ({ params }) => ({ meta: [{ title: `SSR Assets ${params.x} ${params.y}` }], @@ -12,15 +27,3 @@ export const Route = createFileRoute('/a/$x/$y')({ }), component: LeafComponent, }) - -function LeafComponent() { - const params = Route.useParams() - - return ( -
-

- assets-leaf-{params.value.x}-{params.value.y} -

-
- ) -} diff --git a/benchmarks/ssr/scenarios/assets/vue/src/routes/a.$x.tsx b/benchmarks/ssr/scenarios/assets/vue/src/routes/a.$x.tsx index f8038f6f284..e1fe0418e4c 100644 --- a/benchmarks/ssr/scenarios/assets/vue/src/routes/a.$x.tsx +++ b/benchmarks/ssr/scenarios/assets/vue/src/routes/a.$x.tsx @@ -1,6 +1,20 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import '../styles/assets-a.css' +const LevelAComponent = defineComponent({ + setup() { + const params = Route.useParams() + + return () => ( +
+

assets-level-a-{params.value.x}

+ +
+ ) + }, +}) + export const Route = createFileRoute('/a/$x')({ head: ({ params }) => ({ meta: [{ title: `SSR Assets ${params.x}` }], @@ -12,14 +26,3 @@ export const Route = createFileRoute('/a/$x')({ }), component: LevelAComponent, }) - -function LevelAComponent() { - const params = Route.useParams() - - return ( -
-

assets-level-a-{params.value.x}

- -
- ) -} diff --git a/benchmarks/ssr/scenarios/before-load/vue/src/routes/$a.$b.$c.tsx b/benchmarks/ssr/scenarios/before-load/vue/src/routes/$a.$b.$c.tsx index 118e16553b2..178d4071eb5 100644 --- a/benchmarks/ssr/scenarios/before-load/vue/src/routes/$a.$b.$c.tsx +++ b/benchmarks/ssr/scenarios/before-load/vue/src/routes/$a.$b.$c.tsx @@ -1,6 +1,15 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { makeBeforeLoadMarker, type BeforeLoadContext } from '../../../shared' +const LevelCComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () =>
{data.value.marker}
+ }, +}) + export const Route = createFileRoute('/$a/$b/$c')({ beforeLoad: ({ params, context }) => { const parent = context as BeforeLoadContext @@ -15,9 +24,3 @@ export const Route = createFileRoute('/$a/$b/$c')({ }), component: LevelCComponent, }) - -function LevelCComponent() { - const data = Route.useLoaderData() - - return
{data.value.marker}
-} diff --git a/benchmarks/ssr/scenarios/control-flow/vue/src/routes/headers.$id.tsx b/benchmarks/ssr/scenarios/control-flow/vue/src/routes/headers.$id.tsx index ac2ec01b52f..20c1bbe5acf 100644 --- a/benchmarks/ssr/scenarios/control-flow/vue/src/routes/headers.$id.tsx +++ b/benchmarks/ssr/scenarios/control-flow/vue/src/routes/headers.$id.tsx @@ -1,5 +1,14 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' +const HeadersComponent = defineComponent({ + setup() { + const params = Route.useParams() + + return () =>
{`headers-${params.value.id}`}
+ }, +}) + export const Route = createFileRoute('/headers/$id')({ headers: ({ params }) => ({ 'x-bench-route-header': `route-header-${params.id}`, @@ -7,9 +16,3 @@ export const Route = createFileRoute('/headers/$id')({ }), component: HeadersComponent, }) - -function HeadersComponent() { - const params = Route.useParams() - - return
{`headers-${params.value.id}`}
-} diff --git a/benchmarks/ssr/scenarios/control-flow/vue/src/routes/target.$id.tsx b/benchmarks/ssr/scenarios/control-flow/vue/src/routes/target.$id.tsx index 17ade6bf5b7..0bbf4a3dc65 100644 --- a/benchmarks/ssr/scenarios/control-flow/vue/src/routes/target.$id.tsx +++ b/benchmarks/ssr/scenarios/control-flow/vue/src/routes/target.$id.tsx @@ -1,11 +1,14 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' +const TargetComponent = defineComponent({ + setup() { + const params = Route.useParams() + + return () =>
{`target-${params.value.id}`}
+ }, +}) + export const Route = createFileRoute('/target/$id')({ component: TargetComponent, }) - -function TargetComponent() { - const params = Route.useParams() - - return
{`target-${params.value.id}`}
-} diff --git a/benchmarks/ssr/scenarios/global-middleware/vue/src/routes/page.$id.tsx b/benchmarks/ssr/scenarios/global-middleware/vue/src/routes/page.$id.tsx index 2010d0cd2ab..6e837ef0a0d 100644 --- a/benchmarks/ssr/scenarios/global-middleware/vue/src/routes/page.$id.tsx +++ b/benchmarks/ssr/scenarios/global-middleware/vue/src/routes/page.$id.tsx @@ -1,3 +1,4 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { getGlobalMiddlewareContext, @@ -5,6 +6,14 @@ import { type GlobalMiddlewareContext, } from '../../../shared' +const PageComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () =>
{data.value.marker}
+ }, +}) + export const Route = createFileRoute('/page/$id')({ beforeLoad: ({ serverContext }) => ({ globalMiddlewareContext: (serverContext ?? {}) as GlobalMiddlewareContext, @@ -14,9 +23,3 @@ export const Route = createFileRoute('/page/$id')({ }), component: PageComponent, }) - -function PageComponent() { - const data = Route.useLoaderData() - - return
{data.value.marker}
-} diff --git a/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.$b.$c.tsx b/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.$b.$c.tsx index de927a8092f..0a52b9c542a 100644 --- a/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.$b.$c.tsx +++ b/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.$b.$c.tsx @@ -1,6 +1,24 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { makeLevelData } from '../../../shared-data' +const LevelCComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+

{data.value.meta.label}

+
    + {data.value.items.slice(0, 10).map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ) + }, +}) + export const Route = createFileRoute('/$a/$b/$c')({ beforeLoad: ({ params, context }) => { void context @@ -15,18 +33,3 @@ export const Route = createFileRoute('/$a/$b/$c')({ }, component: LevelCComponent, }) - -function LevelCComponent() { - const data = Route.useLoaderData() - - return ( -
-

{data.value.meta.label}

-
    - {data.value.items.slice(0, 10).map((item) => ( -
  • {item.name}
  • - ))} -
-
- ) -} diff --git a/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.$b.tsx b/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.$b.tsx index e1a5f52ae2d..f3943d58e77 100644 --- a/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.$b.tsx +++ b/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.$b.tsx @@ -1,6 +1,25 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLevelData } from '../../../shared-data' +const LevelBComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+

{data.value.meta.label}

+
    + {data.value.items.slice(0, 10).map((item) => ( +
  • {item.name}
  • + ))} +
+ +
+ ) + }, +}) + export const Route = createFileRoute('/$a/$b')({ beforeLoad: ({ params, context }) => { void context @@ -15,19 +34,3 @@ export const Route = createFileRoute('/$a/$b')({ }, component: LevelBComponent, }) - -function LevelBComponent() { - const data = Route.useLoaderData() - - return ( -
-

{data.value.meta.label}

-
    - {data.value.items.slice(0, 10).map((item) => ( -
  • {item.name}
  • - ))} -
- -
- ) -} diff --git a/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.tsx b/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.tsx index b197a8edf15..63bb36949f7 100644 --- a/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.tsx +++ b/benchmarks/ssr/scenarios/loaders/vue/src/routes/$a.tsx @@ -1,6 +1,25 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLevelData } from '../../../shared-data' +const LevelAComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+

{data.value.meta.label}

+
    + {data.value.items.slice(0, 10).map((item) => ( +
  • {item.name}
  • + ))} +
+ +
+ ) + }, +}) + export const Route = createFileRoute('/$a')({ beforeLoad: ({ params, context }) => { void context @@ -15,19 +34,3 @@ export const Route = createFileRoute('/$a')({ }, component: LevelAComponent, }) - -function LevelAComponent() { - const data = Route.useLoaderData() - - return ( -
-

{data.value.meta.label}

-
    - {data.value.items.slice(0, 10).map((item) => ( -
  • {item.name}
  • - ))} -
- -
- ) -} diff --git a/benchmarks/ssr/scenarios/rewrites/vue/src/routes/p.$a.$b.tsx b/benchmarks/ssr/scenarios/rewrites/vue/src/routes/p.$a.$b.tsx index 3fdb7014f45..3c9de1806e6 100644 --- a/benchmarks/ssr/scenarios/rewrites/vue/src/routes/p.$a.$b.tsx +++ b/benchmarks/ssr/scenarios/rewrites/vue/src/routes/p.$a.$b.tsx @@ -1,5 +1,41 @@ +import { defineComponent } from 'vue' import { Link, createFileRoute } from '@tanstack/vue-router' +const LeafComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+

+ rewrite-leaf {data.value.a} {data.value.b} +

+ + leaf-self-link + + + leaf-next-link + + + leaf-parent-link + +
+ ) + }, +}) + export const Route = createFileRoute('/p/$a/$b')({ loader: ({ params }) => ({ a: params.a, @@ -8,32 +44,3 @@ export const Route = createFileRoute('/p/$a/$b')({ }), component: LeafComponent, }) - -function LeafComponent() { - const data = Route.useLoaderData() - - return ( -
-

- rewrite-leaf {data.value.a} {data.value.b} -

- - leaf-self-link - - - leaf-next-link - - - leaf-parent-link - -
- ) -} diff --git a/benchmarks/ssr/scenarios/rewrites/vue/src/routes/p.$a.tsx b/benchmarks/ssr/scenarios/rewrites/vue/src/routes/p.$a.tsx index acb74aabbd8..9702ebedd7b 100644 --- a/benchmarks/ssr/scenarios/rewrites/vue/src/routes/p.$a.tsx +++ b/benchmarks/ssr/scenarios/rewrites/vue/src/routes/p.$a.tsx @@ -1,5 +1,26 @@ +import { defineComponent } from 'vue' import { Link, Outlet, createFileRoute } from '@tanstack/vue-router' +const ParentComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+

rewrite-parent {data.value.a}

+ + parent-branch-link + + +
+ ) + }, +}) + export const Route = createFileRoute('/p/$a')({ loader: ({ params }) => ({ a: params.a, @@ -7,21 +28,3 @@ export const Route = createFileRoute('/p/$a')({ }), component: ParentComponent, }) - -function ParentComponent() { - const data = Route.useLoaderData() - - return ( -
-

rewrite-parent {data.value.a}

- - parent-branch-link - - -
- ) -} diff --git a/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.$b.$c.tsx b/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.$b.$c.tsx index 46d7541c2ba..8ae7d468f1d 100644 --- a/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.$b.$c.tsx +++ b/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.$b.$c.tsx @@ -1,6 +1,21 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { makeLevelData } from '../../../../loaders/shared-data' +const LevelCComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + const params = Route.useParams() + + return () => ( +
+

{`csr-rendered-${params.value.c}`}

+

{data.value.marker}

+
+ ) + }, +}) + export const Route = createFileRoute('/mix/$a/$b/$c')({ ssr: false, loader: async ({ params }) => { @@ -11,15 +26,3 @@ export const Route = createFileRoute('/mix/$a/$b/$c')({ }, component: LevelCComponent, }) - -function LevelCComponent() { - const data = Route.useLoaderData() - const params = Route.useParams() - - return ( -
-

{`csr-rendered-${params.value.c}`}

-

{data.value.marker}

-
- ) -} diff --git a/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.$b.tsx b/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.$b.tsx index bc685478cc2..c10751489ee 100644 --- a/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.$b.tsx +++ b/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.$b.tsx @@ -1,6 +1,22 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLevelData } from '../../../../loaders/shared-data' +const LevelBComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + const params = Route.useParams() + + return () => ( +
+

{`data-only-rendered-${params.value.b}`}

+

{data.value.marker}

+ +
+ ) + }, +}) + export const Route = createFileRoute('/mix/$a/$b')({ ssr: 'data-only', loader: async ({ params }) => { @@ -11,16 +27,3 @@ export const Route = createFileRoute('/mix/$a/$b')({ }, component: LevelBComponent, }) - -function LevelBComponent() { - const data = Route.useLoaderData() - const params = Route.useParams() - - return ( -
-

{`data-only-rendered-${params.value.b}`}

-

{data.value.marker}

- -
- ) -} diff --git a/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.tsx b/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.tsx index 8ab4c7d54dc..c96165c6d65 100644 --- a/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.tsx +++ b/benchmarks/ssr/scenarios/selective-ssr/vue/src/routes/mix.$a.tsx @@ -1,6 +1,22 @@ +import { defineComponent } from 'vue' import { Outlet, createFileRoute } from '@tanstack/vue-router' import { makeLevelData } from '../../../../loaders/shared-data' +const LevelAComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + const params = Route.useParams() + + return () => ( +
+

{`level-a-rendered-${params.value.a}`}

+

{data.value.items[0]?.name}

+ +
+ ) + }, +}) + export const Route = createFileRoute('/mix/$a')({ ssr: true, loader: async ({ params }) => { @@ -8,16 +24,3 @@ export const Route = createFileRoute('/mix/$a')({ }, component: LevelAComponent, }) - -function LevelAComponent() { - const data = Route.useLoaderData() - const params = Route.useParams() - - return ( -
-

{`level-a-rendered-${params.value.a}`}

-

{data.value.items[0]?.name}

- -
- ) -} diff --git a/benchmarks/ssr/scenarios/serialization/vue/src/routes/plain.$id.tsx b/benchmarks/ssr/scenarios/serialization/vue/src/routes/plain.$id.tsx index d41d123f1f1..47fdeb971fd 100644 --- a/benchmarks/ssr/scenarios/serialization/vue/src/routes/plain.$id.tsx +++ b/benchmarks/ssr/scenarios/serialization/vue/src/routes/plain.$id.tsx @@ -1,24 +1,27 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { makePlainSerializationData } from '../../../shared-data' +const PlainComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+

{data.value.label}

+

{data.value.createdAt}

+

{data.value.lookup[0]?.[1].label}

+

{data.value.tags[0]}

+

{data.value.count}

+

{data.value.nested[0]?.id}

+

{data.value.points[0]?.label}

+

{data.value.problem.message}

+
+ ) + }, +}) + export const Route = createFileRoute('/plain/$id')({ loader: ({ params }) => makePlainSerializationData(params.id), component: PlainComponent, }) - -function PlainComponent() { - const data = Route.useLoaderData() - - return ( -
-

{data.value.label}

-

{data.value.createdAt}

-

{data.value.lookup[0]?.[1].label}

-

{data.value.tags[0]}

-

{data.value.count}

-

{data.value.nested[0]?.id}

-

{data.value.points[0]?.label}

-

{data.value.problem.message}

-
- ) -} diff --git a/benchmarks/ssr/scenarios/serialization/vue/src/routes/rich.$id.tsx b/benchmarks/ssr/scenarios/serialization/vue/src/routes/rich.$id.tsx index d50a3f292de..1ee86cefd30 100644 --- a/benchmarks/ssr/scenarios/serialization/vue/src/routes/rich.$id.tsx +++ b/benchmarks/ssr/scenarios/serialization/vue/src/routes/rich.$id.tsx @@ -1,24 +1,27 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { makeRichSerializationData } from '../../../shared-data' +const RichComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
+

{data.value.label}

+

{data.value.createdAt.toISOString()}

+

{data.value.lookup.get('k0')?.label}

+

{Array.from(data.value.tags)[0]}

+

{data.value.count.toString()}

+

{data.value.nested[0]?.id}

+

{data.value.points[0]?.label}

+

{data.value.problem.message}

+
+ ) + }, +}) + export const Route = createFileRoute('/rich/$id')({ loader: ({ params }) => makeRichSerializationData(params.id), component: RichComponent, }) - -function RichComponent() { - const data = Route.useLoaderData() - - return ( -
-

{data.value.label}

-

{data.value.createdAt.toISOString()}

-

{data.value.lookup.get('k0')?.label}

-

{Array.from(data.value.tags)[0]}

-

{data.value.count.toString()}

-

{data.value.nested[0]?.id}

-

{data.value.points[0]?.label}

-

{data.value.problem.message}

-
- ) -} diff --git a/benchmarks/ssr/scenarios/server-fns/vue/src/routes/ssr-call.$id.tsx b/benchmarks/ssr/scenarios/server-fns/vue/src/routes/ssr-call.$id.tsx index 8abe77c2af6..45fad23dc01 100644 --- a/benchmarks/ssr/scenarios/server-fns/vue/src/routes/ssr-call.$id.tsx +++ b/benchmarks/ssr/scenarios/server-fns/vue/src/routes/ssr-call.$id.tsx @@ -1,6 +1,17 @@ +import { defineComponent } from 'vue' import { createFileRoute } from '@tanstack/vue-router' import { echoGet } from '../fns' +const SsrCallComponent = defineComponent({ + setup() { + const data = Route.useLoaderData() + + return () => ( +
{data.value.marker}
+ ) + }, +}) + export const Route = createFileRoute('/ssr-call/$id')({ loader: async ({ params }) => { const result = await echoGet({ @@ -15,9 +26,3 @@ export const Route = createFileRoute('/ssr-call/$id')({ }, component: SsrCallComponent, }) - -function SsrCallComponent() { - const data = Route.useLoaderData() - - return
{data.value.marker}
-} diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 3556f4ab240..2e2abe43f27 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -418,3 +418,29 @@ If you want to configure to remount all route components upon `params` change, u ```tsx remountDeps: ({ params }) => params ``` + +### `experimental_concurrentRenderFrames` property + +- Type: `boolean` +- Optional +- Defaults to `false` +- **Experimental.** When `true`, the React adapter publishes router state to React as one immutable _render frame_ per navigation, instead of through the individual store subscriptions that `useSyncExternalStore` backs. +- Enable it if you need React's `` — or any other transition-only behaviour — to engage across a navigation. Router state otherwise reaches components through `useSyncExternalStore`, which React schedules at a synchronous lane from the store's own subscription callback, after the `startTransition` scope has exited. That update is therefore never a transition, and `` only runs for transitions. +- Selector behaviour is unchanged: a consumer re-renders only when its own selection changes. + +Two behaviour changes to know about before enabling it: + +- **A client-rendered app does not present route pending UI on a navigation.** Suspension consolidates at a single boundary around the route tree, so that a frame is published and acknowledged atomically. On the first render that boundary mounts with the route tree, so its fallback — built from the root route — is shown as usual. On a later navigation it is already mounted, and the navigation is a transition: React keeps the route on screen rather than replacing it with a fallback. So for client navigations `pendingComponent` is not rendered, and `pendingMs` and `pendingMinMs` have nothing to time. That is the concurrent behaviour the option exists to produce — the previous route stays visible until the next one is ready — but it is a behaviour change, so provide progress UI outside the route tree, or from the route being left, using `status` and `isLoading`, which stay live throughout. **A server-rendered app keeps its route-level boundaries** — that is what its streamed HTML describes, and the boundary decides an element type, so it cannot appear once hydration finishes without remounting the route tree. Such an app therefore gives up atomic acknowledgement: a child that suspends resolves at its own boundary, so a frame can be acknowledged while part of the tree is still pending, exactly as it is without this option. +- **`location` and `matches` lag the imperative head while a navigation is in flight**, by design: a component that renders during a navigation observes the route on screen rather than the one being prepared. `status` and `isLoading` are deliberately exempt, so progress UI still sees a navigation start and finish. An explicit `matchRoute({ pending: true })` also still resolves against the head, so destination-aware indicators keep working, and so does `useCanGoBack`: `history.back()` acts on the browser's history rather than on the frame on screen, so the answer has to describe the history the control would actually move. +- **`InnerWrap` is outside the route tree, so it reads the committed route.** It wraps the whole match tree, and like any consumer outside that tree it advances only when a navigation commits — deliberately, so the visible surroundings do not jump to the destination while the old route is still on screen. Something rendered inside it that suspends until it describes the destination would wait for a commit its own suspension prevents; that applies to any outside consumer, not just `InnerWrap`. +- **An imperative navigation resolves against the route on screen, which is wrong for one caller.** `useNavigate` resolves relative paths and search or param updaters against the publication its position presents, so a handler on the visible route navigates from what the user is looking at rather than from the route being prepared. A destination component calling `navigate` from its *mount* layout effect is the exception: React runs layout effects bottom-up, and the frame commits from an ancestor's, so that call runs before the frame it rendered from has committed and resolves against the route being left. Navigate from an event or a passive effect, or pass `_fromLocation` explicitly, if that matters to you. +- **A reader with no previous answer can see the route being prepared.** A consumer that mounts during a navigation, or whose `select` function changes while one is in flight, has no earlier selection to compare against and no way to tell which tree is rendering it, so that first render can read the staged route rather than the visible one. Consumers already mounted with a stable selector are isolated. +- **Replacing the router under a mounted provider does not change which path the tree uses.** Whether a component reads through the frame path is decided at its first render, because that decision gates which hooks it calls; a component handed a router configured the other way would otherwise change hook shape and crash on the hook order. The consequence is that swapping a router for one with a different setting leaves the tree on the setting it mounted with — enabling the option on a replacement router does not activate it. Recreate the tree (a fresh `RouterProvider`, or a `key` on it) when you replace a router with one configured differently. +- **Every reader goes through the frame path, including one that names a router explicitly.** `useRouterState({ router })` pointing at a router with no provider above it reads that router's store head — the same content as before — but through React state rather than `useSyncExternalStore`, so its updates are no longer flushed synchronously. + +```tsx +const router = createRouter({ + routeTree, + experimental_concurrentRenderFrames: true, +}) +``` diff --git a/docs/router/api/router/RouterStateType.md b/docs/router/api/router/RouterStateType.md index 4da9cd9bb63..d69a053fe64 100644 --- a/docs/router/api/router/RouterStateType.md +++ b/docs/router/api/router/RouterStateType.md @@ -9,6 +9,7 @@ status. ```tsx type RouterState = { + frameId: number status: 'pending' | 'idle' isLoading: boolean matches: Array @@ -21,6 +22,24 @@ type RouterState = { The `RouterState` type contains all of the properties that are available on the router state. +### `frameId` property + +- Type: `number` +- Identity for one atomically assembled snapshot of **route content** — + `location`, `matches` and `resolvedLocation`. Every state the router + assembles gets a new, larger value. +- It identifies a snapshot rather than a navigation: a single navigation + assembles several, and the value carries no meaning beyond comparison and + ordering. Do not derive a location, a match, or a count of navigations from + it. +- **Not a change token for the whole state.** `status` and `isLoading` are + navigation progress rather than route content, and a framework adapter may + overlay them onto a snapshot a component is already presenting while keeping + its `frameId` — the identity is what the adapter matches an acknowledgement + against, so changing it there would orphan the render it belongs to. Two + states sharing a `frameId` therefore agree on route content but can differ in + progress. Select the fields you depend on rather than versioning on this. + ### `status` property - Type: `'pending' | 'idle'` diff --git a/docs/router/how-to/share-search-params-across-routes.md b/docs/router/how-to/share-search-params-across-routes.md index e1e24c1e0d1..23a202b1766 100644 --- a/docs/router/how-to/share-search-params-across-routes.md +++ b/docs/router/how-to/share-search-params-across-routes.md @@ -2,8 +2,6 @@ title: Share Search Parameters Across Routes --- -# How to Share Search Parameters Across Routes - Search parameters automatically inherit from parent routes in TanStack Router. When a parent route validates search parameters, child routes can access them via `Route.useSearch()` alongside their own parameters. ## How Parameter Inheritance Works diff --git a/docs/start/framework/react/guide/cdn-asset-urls.md b/docs/start/framework/react/guide/cdn-asset-urls.md index 28c9c8c0302..ac5f3e7b576 100644 --- a/docs/start/framework/react/guide/cdn-asset-urls.md +++ b/docs/start/framework/react/guide/cdn-asset-urls.md @@ -3,8 +3,6 @@ id: cdn-asset-urls title: CDN Asset URLs --- -# CDN Asset URLs - > **Experimental:** `transformAssets` is experimental and subject to change. Use this guide when you need TanStack Start to rewrite manifest-managed asset URLs at runtime. The most common use case is serving JavaScript and CSS from a CDN whose origin is known only when the server starts, or varies per request. diff --git a/docs/start/framework/react/guide/client-entry-point.md b/docs/start/framework/react/guide/client-entry-point.md index 02d0ecd426f..3d81a13ac21 100644 --- a/docs/start/framework/react/guide/client-entry-point.md +++ b/docs/start/framework/react/guide/client-entry-point.md @@ -3,8 +3,6 @@ id: client-entry-point title: Client Entry Point --- -# Client Entry Point - > [!NOTE] > The client entry point is **optional** out of the box. If not provided, TanStack Start will automatically handle the client entry point for you using the below as a default. diff --git a/docs/start/framework/react/guide/early-hints.md b/docs/start/framework/react/guide/early-hints.md index da633a84d75..a5913952e64 100644 --- a/docs/start/framework/react/guide/early-hints.md +++ b/docs/start/framework/react/guide/early-hints.md @@ -3,8 +3,6 @@ id: early-hints title: Early Hints --- -# Early Hints - > **Experimental:** Early Hints are experimental and subject to change. HTTP `103 Early Hints` lets your server tell the browser about important resources before the final HTML response is ready. TanStack Start can collect route assets and route `head().links`, then call your server entry so your runtime can send `103` responses. diff --git a/docs/start/framework/react/guide/server-entry-point.md b/docs/start/framework/react/guide/server-entry-point.md index 5acc8ee550b..127b04fc86b 100644 --- a/docs/start/framework/react/guide/server-entry-point.md +++ b/docs/start/framework/react/guide/server-entry-point.md @@ -3,8 +3,6 @@ id: server-entry-point title: Server Entry Point --- -# Server Entry Point - > [!NOTE] > The server entry point is **optional** out of the box. If not provided, TanStack Start will automatically handle the server entry point for you using the below as a default. diff --git a/docs/start/framework/solid/guide/client-entry-point.md b/docs/start/framework/solid/guide/client-entry-point.md index b73408e8e46..929efc7a2d6 100644 --- a/docs/start/framework/solid/guide/client-entry-point.md +++ b/docs/start/framework/solid/guide/client-entry-point.md @@ -3,8 +3,6 @@ id: client-entry-point title: Client Entry Point --- -# Client Entry Point - > [!NOTE] > The client entry point is **optional** out of the box. If not provided, TanStack Start will automatically handle the client entry point for you using the below as a default. diff --git a/e2e/react-router/basic-react-query-file-based/src/routeTree.gen.ts b/e2e/react-router/basic-react-query-file-based/src/routeTree.gen.ts index 115e4bce473..4680fc8580e 100644 --- a/e2e/react-router/basic-react-query-file-based/src/routeTree.gen.ts +++ b/e2e/react-router/basic-react-query-file-based/src/routeTree.gen.ts @@ -86,12 +86,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | '/' - | '/posts' - | '/posts/$postId' - | '/posts/' - | '/layout-a' - | '/layout-b' + '/' | '/posts' | '/posts/$postId' | '/posts/' | '/layout-a' | '/layout-b' fileRoutesByTo: FileRoutesByTo to: '/' | '/posts/$postId' | '/posts' | '/layout-a' | '/layout-b' id: diff --git a/e2e/react-router/generator-cli-only/src/routeTree.gen.ts b/e2e/react-router/generator-cli-only/src/routeTree.gen.ts index 9676858c7c1..28cc971c425 100644 --- a/e2e/react-router/generator-cli-only/src/routeTree.gen.ts +++ b/e2e/react-router/generator-cli-only/src/routeTree.gen.ts @@ -89,12 +89,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | '/' - | '/posts' - | '/posts/$postId' - | '/posts/' - | '/route-a' - | '/route-b' + '/' | '/posts' | '/posts/$postId' | '/posts/' | '/route-a' | '/route-b' fileRoutesByTo: FileRoutesByTo to: '/' | '/posts/$postId' | '/posts' | '/route-a' | '/route-b' id: diff --git a/e2e/react-router/view-transitions/src/main.tsx b/e2e/react-router/view-transitions/src/main.tsx index 065d69a2cf8..7c4954c04c4 100644 --- a/e2e/react-router/view-transitions/src/main.tsx +++ b/e2e/react-router/view-transitions/src/main.tsx @@ -38,6 +38,17 @@ const router = createRouter({ // return [`slide-${direction}`] // }, // }, + + // Run this fixture on the render-frame path, so its tests act as a + // regression guard that the option does not break `viewTransition: true`. + // + // They do not, and cannot, prove the option's own behaviour: they assert on + // `document.startViewTransition`, which `viewTransition: true` calls + // directly, and they pass with the option either way (verified). React's + // `` — what the option actually unblocks — is canary-only, + // so it cannot be exercised on the React version this repo pins. The unit + // tests cover the protocol; see the PR for the measured evidence. + experimental_concurrentRenderFrames: true, }) // Register things for typesafety diff --git a/e2e/react-router/view-transitions/src/routeTree.gen.ts b/e2e/react-router/view-transitions/src/routeTree.gen.ts index 8982fca68f8..e37b6df3c06 100644 --- a/e2e/react-router/view-transitions/src/routeTree.gen.ts +++ b/e2e/react-router/view-transitions/src/routeTree.gen.ts @@ -74,12 +74,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | '/' - | '/posts' - | '/explore' - | '/how-it-works' - | '/posts/$postId' - | '/posts/' + '/' | '/posts' | '/explore' | '/how-it-works' | '/posts/$postId' | '/posts/' fileRoutesByTo: FileRoutesByTo to: '/' | '/explore' | '/how-it-works' | '/posts/$postId' | '/posts' id: diff --git a/e2e/react-router/view-transitions/tests/app.spec.ts b/e2e/react-router/view-transitions/tests/app.spec.ts index 472bd6ed0bb..40c19cbd885 100644 --- a/e2e/react-router/view-transitions/tests/app.spec.ts +++ b/e2e/react-router/view-transitions/tests/app.spec.ts @@ -1,10 +1,136 @@ -import { test } from '@playwright/test' +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +/** One observed call to `document.startViewTransition`. */ +type ViewTransitionRecord = { + /** Transition types active on the document while the transition ran. */ + types: Array + /** Pseudo-elements the browser animated for the transition. */ + pseudos: Array +} + +declare global { + interface Window { + __viewTransitions: Array + /** Whether this browser has the View Transitions API at all. */ + __viewTransitionsSupported: boolean + } +} + +const KNOWN_TYPES = ['slide-left', 'slide-right', 'warp'] + +/** + * Wrap `document.startViewTransition` before any app code runs, and sample the + * live animations once the browser reports the transition as ready. Asserting + * on the real API is the only way to tell a view transition apart from a plain + * navigation that happens to end on the right page. + */ +async function recordViewTransitions(page: Page) { + await page.addInitScript((knownTypes: Array) => { + window.__viewTransitions = [] + const original = document.startViewTransition?.bind(document) + // Recorded on the page rather than returned from `addInitScript`, which + // resolves to a handle for removing the script, not to the script's value. + window.__viewTransitionsSupported = Boolean(original) + if (!original) { + return + } + document.startViewTransition = ((...args: Array) => { + const record: ViewTransitionRecord = { types: [], pseudos: [] } + window.__viewTransitions.push(record) + const transition = original(...(args as [any])) + transition.ready + .then(() => { + record.pseudos = document + .getAnimations() + .map((animation) => (animation.effect as any)?.pseudoElement) + .filter((pseudo): pseudo is string => Boolean(pseudo)) + record.types = knownTypes.filter((type) => + document.documentElement.matches( + `:active-view-transition-type(${type})`, + ), + ) + }) + .catch(() => {}) + return transition + }) as typeof document.startViewTransition + }, KNOWN_TYPES) +} + +const getRecords = (page: Page) => page.evaluate(() => window.__viewTransitions) + +/** + * Without the API there is nothing to record, so the polls below would wait for + * records that can never arrive and fail on timeout. Skip instead: absent + * support is not a failing assertion about this change. + */ +const supportsViewTransitions = (page: Page) => + page.evaluate(() => window.__viewTransitionsSupported) test.beforeEach(async ({ page }) => { + await recordViewTransitions(page) await page.goto('/') }) -test('placeholder test', async ({ page }) => { - // This is a placeholder test - await page.waitForLoadState('networkidle') +test('a viewTransition navigation starts a real view transition', async ({ + page, +}) => { + test.skip( + !(await supportsViewTransitions(page)), + 'browser does not support document.startViewTransition', + ) + + await page.getByRole('link', { name: 'Next Page' }).click() + await expect(page.getByRole('heading')).toContainText( + 'This example demonstrates a variety of custom page transitions', + ) + + await expect.poll(async () => (await getRecords(page)).length).toBe(1) +}) + +test('the transition pairs the shared element across the navigation', async ({ + page, +}) => { + test.skip( + !(await supportsViewTransitions(page)), + 'browser does not support document.startViewTransition', + ) + + await page.getByRole('link', { name: 'Next Page' }).click() + + await expect + .poll(async () => (await getRecords(page))[0]?.pseudos ?? []) + .toEqual( + expect.arrayContaining([ + '::view-transition-group(main-content)', + '::view-transition-old(main-content)', + '::view-transition-new(main-content)', + ]), + ) +}) + +test('the configured viewTransition types are applied to the document', async ({ + page, +}) => { + test.skip( + !(await supportsViewTransitions(page)), + 'browser does not support document.startViewTransition', + ) + + const supportsTypes = await page.evaluate(() => + Boolean( + window.CSS?.supports?.('selector(:active-view-transition-type(a))'), + ), + ) + test.skip(!supportsTypes, 'browser does not support view transition types') + + await page.getByRole('link', { name: 'Next Page' }).click() + await expect + .poll(async () => (await getRecords(page))[0]?.types ?? []) + .toEqual(['slide-left']) + + await page.getByRole('link', { name: 'Previous Page' }).click() + await expect + .poll(async () => (await getRecords(page))[1]?.types ?? []) + .toEqual(['slide-right']) }) diff --git a/e2e/react-start/query-integration/src/routeTree.gen.ts b/e2e/react-start/query-integration/src/routeTree.gen.ts index ee7ef43f26c..40f8d4fe662 100644 --- a/e2e/react-start/query-integration/src/routeTree.gen.ts +++ b/e2e/react-start/query-integration/src/routeTree.gen.ts @@ -57,10 +57,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | '/' - | '/useQuery' - | '/useSuspenseQuery' - | '/loader-fetchQuery/$type' + '/' | '/useQuery' | '/useSuspenseQuery' | '/loader-fetchQuery/$type' fileRoutesByTo: FileRoutesByTo to: '/' | '/useQuery' | '/useSuspenseQuery' | '/loader-fetchQuery/$type' id: diff --git a/e2e/react-start/transform-asset-urls/src/server.ts b/e2e/react-start/transform-asset-urls/src/server.ts index 241fc2fe51b..a51f29ad665 100644 --- a/e2e/react-start/transform-asset-urls/src/server.ts +++ b/e2e/react-start/transform-asset-urls/src/server.ts @@ -83,4 +83,18 @@ const handler = createStartHandler( : defaultStreamHandler, ) -export default createServerEntry({ fetch: handler }) +export default createServerEntry({ + fetch(request, options) { + const scriptUrl = request.headers.get('x-test-script-url') + if (scriptUrl) { + return createStartHandler({ + handler: defaultStreamHandler, + transformAssets: ({ kind, url }) => ({ + href: kind === 'script' ? scriptUrl : url, + }), + })(request, options) + } + + return handler(request, options) + }, +}) diff --git a/e2e/react-start/transform-asset-urls/tests/script-url-escaping.spec.ts b/e2e/react-start/transform-asset-urls/tests/script-url-escaping.spec.ts new file mode 100644 index 00000000000..22251df671c --- /dev/null +++ b/e2e/react-start/transform-asset-urls/tests/script-url-escaping.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' + +for (const [name, scriptUrl] of [ + [ + 'closing script tag', + '