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 170a6beadb7..9a48aa85139 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 @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as PostsRouteImport } from './routes/posts' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as IndexRouteImport } from './routes/index' +import { Route as TransitionIndexRouteImport } from './routes/transition/index' import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' @@ -32,6 +33,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const TransitionIndexRoute = TransitionIndexRouteImport.update({ + id: '/transition/', + path: '/transition/', + getParentRoute: () => rootRouteImport, +} as any) const PostsIndexRoute = PostsIndexRouteImport.update({ id: '/', path: '/', @@ -62,6 +68,7 @@ export interface FileRoutesByFullPath { '/posts': typeof PostsRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/posts/': typeof PostsIndexRoute + '/transition': typeof TransitionIndexRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute } @@ -69,6 +76,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/posts/$postId': typeof PostsPostIdRoute '/posts': typeof PostsIndexRoute + '/transition': typeof TransitionIndexRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute } @@ -80,6 +88,7 @@ export interface FileRoutesById { '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/posts/': typeof PostsIndexRoute + '/transition/': typeof TransitionIndexRoute '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute } @@ -90,10 +99,17 @@ export interface FileRouteTypes { | '/posts' | '/posts/$postId' | '/posts/' + | '/transition' | '/layout-a' | '/layout-b' fileRoutesByTo: FileRoutesByTo - to: '/' | '/posts/$postId' | '/posts' | '/layout-a' | '/layout-b' + to: + | '/' + | '/posts/$postId' + | '/posts' + | '/transition' + | '/layout-a' + | '/layout-b' id: | '__root__' | '/' @@ -102,6 +118,7 @@ export interface FileRouteTypes { | '/_layout/_layout-2' | '/posts/$postId' | '/posts/' + | '/transition/' | '/_layout/_layout-2/layout-a' | '/_layout/_layout-2/layout-b' fileRoutesById: FileRoutesById @@ -110,6 +127,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute LayoutRoute: typeof LayoutRouteWithChildren PostsRoute: typeof PostsRouteWithChildren + TransitionIndexRoute: typeof TransitionIndexRoute } declare module '@tanstack/react-router' { @@ -135,6 +153,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/transition/': { + id: '/transition/' + path: '/transition' + fullPath: '/transition' + preLoaderRoute: typeof TransitionIndexRouteImport + parentRoute: typeof rootRouteImport + } '/posts/': { id: '/posts/' path: '/' @@ -214,6 +239,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LayoutRoute: LayoutRouteWithChildren, PostsRoute: PostsRouteWithChildren, + TransitionIndexRoute: TransitionIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/e2e/react-router/basic-react-query-file-based/src/routes/transition/index.tsx b/e2e/react-router/basic-react-query-file-based/src/routes/transition/index.tsx new file mode 100644 index 00000000000..98d61b89ab8 --- /dev/null +++ b/e2e/react-router/basic-react-query-file-based/src/routes/transition/index.tsx @@ -0,0 +1,53 @@ +import { Link, createFileRoute } from '@tanstack/react-router' +import { Suspense, useMemo } from 'react' +import { queryOptions, useQuery } from '@tanstack/react-query' +import { z } from 'zod' + +const searchSchema = z.object({ + n: z.number().default(1), +}) + +const doubleQueryOptions = (n: number) => + queryOptions({ + queryKey: ['transition-double', n], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return n * 2 + }, + placeholderData: (oldData) => oldData, + }) + +export const Route = createFileRoute('/transition/')({ + validateSearch: searchSchema, + loader: ({ context: { queryClient }, location }) => { + const { n } = searchSchema.parse(location.search) + return queryClient.ensureQueryData(doubleQueryOptions(n)) + }, + component: TransitionPage, +}) + +function TransitionPage() { + const search = Route.useSearch() + + const doubleQuery = useQuery(doubleQueryOptions(search.n)) + + return ( + + + ({ n: s.n + 1 })} + > + Increase + + + + n: {search.n} + double: {doubleQuery.data} + + + + ) +} diff --git a/e2e/react-router/basic-react-query-file-based/tests/transition.spec.ts b/e2e/react-router/basic-react-query-file-based/tests/transition.spec.ts new file mode 100644 index 00000000000..1d3462bd649 --- /dev/null +++ b/e2e/react-router/basic-react-query-file-based/tests/transition.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' + +test('react-query transitions keep previous data during navigation', async ({ + page, +}) => { + await page.goto('/transition') + + await expect(page.getByTestId('n-value')).toContainText('n: 1') + await expect(page.getByTestId('double-value')).toContainText('double: 2') + + const bodySnapshots: Array = [] + + const interval = setInterval(async () => { + const text = await page + .locator('body') + .textContent() + .catch(() => '') + if (text) bodySnapshots.push(text) + }, 50) + + await page.getByTestId('increase-button').click() + + await page.waitForTimeout(200) + + clearInterval(interval) + + await expect(page.getByTestId('n-value')).toContainText('n: 2', { + timeout: 2_000, + }) + await expect(page.getByTestId('double-value')).toContainText('double: 4', { + timeout: 2_000, + }) + + const sawLoading = bodySnapshots.some((text) => text.includes('Loading...')) + + expect(sawLoading).toBeFalsy() +}) diff --git a/e2e/solid-router/basic-file-based/src/routeTree.gen.ts b/e2e/solid-router/basic-file-based/src/routeTree.gen.ts index 136c0dc9da0..544ba70d76e 100644 --- a/e2e/solid-router/basic-file-based/src/routeTree.gen.ts +++ b/e2e/solid-router/basic-file-based/src/routeTree.gen.ts @@ -21,6 +21,7 @@ import { Route as LayoutRouteImport } from './routes/_layout' import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route' import { Route as NonNestedRouteRouteImport } from './routes/non-nested/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as TransitionIndexRouteImport } from './routes/transition/index' import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index' import { Route as RelativeIndexRouteImport } from './routes/relative/index' import { Route as RedirectIndexRouteImport } from './routes/redirect/index' @@ -158,6 +159,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const TransitionIndexRoute = TransitionIndexRouteImport.update({ + id: '/transition/', + path: '/transition/', + getParentRoute: () => rootRouteImport, +} as any) const SearchParamsIndexRoute = SearchParamsIndexRouteImport.update({ id: '/', path: '/', @@ -600,6 +606,7 @@ export interface FileRoutesByFullPath { '/redirect': typeof RedirectIndexRoute '/relative': typeof RelativeIndexRoute '/search-params/': typeof SearchParamsIndexRoute + '/transition': typeof TransitionIndexRoute '/non-nested/named/$baz': typeof NonNestedNamedBazRouteRouteWithChildren '/non-nested/path/baz': typeof NonNestedPathBazRouteRouteWithChildren '/non-nested/prefix/prefix{$baz}': typeof NonNestedPrefixPrefixChar123bazChar125RouteRouteWithChildren @@ -684,6 +691,7 @@ export interface FileRoutesByTo { '/redirect': typeof RedirectIndexRoute '/relative': typeof RelativeIndexRoute '/search-params': typeof SearchParamsIndexRoute + '/transition': typeof TransitionIndexRoute '/params-ps/named/$foo': typeof ParamsPsNamedFooRouteRouteWithChildren '/params-ps/non-nested/$foo': typeof ParamsPsNonNestedFooRouteRouteWithChildren '/insidelayout': typeof groupLayoutInsidelayoutRoute @@ -771,6 +779,7 @@ export interface FileRoutesById { '/redirect/': typeof RedirectIndexRoute '/relative/': typeof RelativeIndexRoute '/search-params/': typeof SearchParamsIndexRoute + '/transition/': typeof TransitionIndexRoute '/non-nested/named/$baz': typeof NonNestedNamedBazRouteRouteWithChildren '/non-nested/path/baz': typeof NonNestedPathBazRouteRouteWithChildren '/non-nested/prefix/prefix{$baz}': typeof NonNestedPrefixPrefixChar123bazChar125RouteRouteWithChildren @@ -860,6 +869,7 @@ export interface FileRouteTypes { | '/redirect' | '/relative' | '/search-params/' + | '/transition' | '/non-nested/named/$baz' | '/non-nested/path/baz' | '/non-nested/prefix/prefix{$baz}' @@ -944,6 +954,7 @@ export interface FileRouteTypes { | '/redirect' | '/relative' | '/search-params' + | '/transition' | '/params-ps/named/$foo' | '/params-ps/non-nested/$foo' | '/insidelayout' @@ -1030,6 +1041,7 @@ export interface FileRouteTypes { | '/redirect/' | '/relative/' | '/search-params/' + | '/transition/' | '/non-nested/named/$baz' | '/non-nested/path/baz' | '/non-nested/prefix/prefix{$baz}' @@ -1112,6 +1124,7 @@ export interface RootRouteChildren { ParamsPsIndexRoute: typeof ParamsPsIndexRoute RedirectIndexRoute: typeof RedirectIndexRoute RelativeIndexRoute: typeof RelativeIndexRoute + TransitionIndexRoute: typeof TransitionIndexRoute ParamsPsNamedFooRouteRoute: typeof ParamsPsNamedFooRouteRouteWithChildren groupSubfolderInsideRoute: typeof groupSubfolderInsideRoute ParamsPsNamedPrefixChar123fooChar125Route: typeof ParamsPsNamedPrefixChar123fooChar125Route @@ -1216,6 +1229,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/transition/': { + id: '/transition/' + path: '/transition' + fullPath: '/transition' + preLoaderRoute: typeof TransitionIndexRouteImport + parentRoute: typeof rootRouteImport + } '/search-params/': { id: '/search-params/' path: '/' @@ -2104,6 +2124,7 @@ const rootRouteChildren: RootRouteChildren = { ParamsPsIndexRoute: ParamsPsIndexRoute, RedirectIndexRoute: RedirectIndexRoute, RelativeIndexRoute: RelativeIndexRoute, + TransitionIndexRoute: TransitionIndexRoute, ParamsPsNamedFooRouteRoute: ParamsPsNamedFooRouteRouteWithChildren, groupSubfolderInsideRoute: groupSubfolderInsideRoute, ParamsPsNamedPrefixChar123fooChar125Route: diff --git a/e2e/solid-router/basic-file-based/src/routes/transition/index.tsx b/e2e/solid-router/basic-file-based/src/routes/transition/index.tsx new file mode 100644 index 00000000000..59ca7028be8 --- /dev/null +++ b/e2e/solid-router/basic-file-based/src/routes/transition/index.tsx @@ -0,0 +1,48 @@ +import { Link, createFileRoute } from '@tanstack/solid-router' +import { Suspense, createResource } from 'solid-js' +import { z } from 'zod' + +export const Route = createFileRoute('/transition/')({ + validateSearch: z.object({ + n: z.number().default(1), + }), + component: Home, +}) + +function Home() { + return ( + + ({ n: s.n + 1 })} + > + Increase + + + + + ) +} + +function Result() { + const searchQuery = Route.useSearch() + + const [doubleQuery] = createResource( + () => searchQuery().n, + async (n) => { + await new Promise((r) => setTimeout(r, 1000)) + return n * 2 + }, + ) + + return ( + + + n: {searchQuery().n} + double: {doubleQuery()} + + + ) +} diff --git a/e2e/solid-router/basic-file-based/tests/transition.spec.ts b/e2e/solid-router/basic-file-based/tests/transition.spec.ts new file mode 100644 index 00000000000..f5806785a78 --- /dev/null +++ b/e2e/solid-router/basic-file-based/tests/transition.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test' + +test('transitions should keep old values visible during navigation', async ({ + page, +}) => { + await page.goto('/transition') + + await expect(page.getByTestId('n-value')).toContainText('n: 1') + await expect(page.getByTestId('double-value')).toContainText('double: 2') + + const bodyTexts: Array = [] + + const pollInterval = setInterval(async () => { + const text = await page + .locator('body') + .textContent() + .catch(() => '') + if (text) bodyTexts.push(text) + }, 50) + + await page.getByTestId('increase-button').click() + + await page.waitForTimeout(200) + + clearInterval(pollInterval) + + await expect(page.getByTestId('n-value')).toContainText('n: 2', { + timeout: 2000, + }) + await expect(page.getByTestId('double-value')).toContainText('double: 4', { + timeout: 2000, + }) + + // With proper transitions, old values should remain visible until new ones arrive + const hasLoadingText = bodyTexts.some((text) => text.includes('Loading...')) + + if (hasLoadingText) { + throw new Error( + 'FAILED: "Loading..." appeared during navigation. ' + + 'Solid Router should use transitions to keep old values visible.', + ) + } +}) diff --git a/e2e/solid-router/basic-solid-query-file-based/src/routeTree.gen.ts b/e2e/solid-router/basic-solid-query-file-based/src/routeTree.gen.ts index 325f0ccfc8f..652b25fab68 100644 --- a/e2e/solid-router/basic-solid-query-file-based/src/routeTree.gen.ts +++ b/e2e/solid-router/basic-solid-query-file-based/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as PostsRouteImport } from './routes/posts' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as IndexRouteImport } from './routes/index' +import { Route as TransitionIndexRouteImport } from './routes/transition/index' import { Route as PostsIndexRouteImport } from './routes/posts.index' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId' import { Route as LayoutLayout2RouteImport } from './routes/_layout/_layout-2' @@ -32,6 +33,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const TransitionIndexRoute = TransitionIndexRouteImport.update({ + id: '/transition/', + path: '/transition/', + getParentRoute: () => rootRouteImport, +} as any) const PostsIndexRoute = PostsIndexRouteImport.update({ id: '/', path: '/', @@ -62,6 +68,7 @@ export interface FileRoutesByFullPath { '/posts': typeof PostsRouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/posts/': typeof PostsIndexRoute + '/transition': typeof TransitionIndexRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute } @@ -69,6 +76,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/posts/$postId': typeof PostsPostIdRoute '/posts': typeof PostsIndexRoute + '/transition': typeof TransitionIndexRoute '/layout-a': typeof LayoutLayout2LayoutARoute '/layout-b': typeof LayoutLayout2LayoutBRoute } @@ -80,6 +88,7 @@ export interface FileRoutesById { '/_layout/_layout-2': typeof LayoutLayout2RouteWithChildren '/posts/$postId': typeof PostsPostIdRoute '/posts/': typeof PostsIndexRoute + '/transition/': typeof TransitionIndexRoute '/_layout/_layout-2/layout-a': typeof LayoutLayout2LayoutARoute '/_layout/_layout-2/layout-b': typeof LayoutLayout2LayoutBRoute } @@ -90,10 +99,17 @@ export interface FileRouteTypes { | '/posts' | '/posts/$postId' | '/posts/' + | '/transition' | '/layout-a' | '/layout-b' fileRoutesByTo: FileRoutesByTo - to: '/' | '/posts/$postId' | '/posts' | '/layout-a' | '/layout-b' + to: + | '/' + | '/posts/$postId' + | '/posts' + | '/transition' + | '/layout-a' + | '/layout-b' id: | '__root__' | '/' @@ -102,6 +118,7 @@ export interface FileRouteTypes { | '/_layout/_layout-2' | '/posts/$postId' | '/posts/' + | '/transition/' | '/_layout/_layout-2/layout-a' | '/_layout/_layout-2/layout-b' fileRoutesById: FileRoutesById @@ -110,6 +127,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute LayoutRoute: typeof LayoutRouteWithChildren PostsRoute: typeof PostsRouteWithChildren + TransitionIndexRoute: typeof TransitionIndexRoute } declare module '@tanstack/solid-router' { @@ -135,6 +153,13 @@ declare module '@tanstack/solid-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/transition/': { + id: '/transition/' + path: '/transition' + fullPath: '/transition' + preLoaderRoute: typeof TransitionIndexRouteImport + parentRoute: typeof rootRouteImport + } '/posts/': { id: '/posts/' path: '/' @@ -214,6 +239,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, LayoutRoute: LayoutRouteWithChildren, PostsRoute: PostsRouteWithChildren, + TransitionIndexRoute: TransitionIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/e2e/solid-router/basic-solid-query-file-based/src/routes/transition/index.tsx b/e2e/solid-router/basic-solid-query-file-based/src/routes/transition/index.tsx new file mode 100644 index 00000000000..6faef6397de --- /dev/null +++ b/e2e/solid-router/basic-solid-query-file-based/src/routes/transition/index.tsx @@ -0,0 +1,53 @@ +import { Link, createFileRoute } from '@tanstack/solid-router' +import { Suspense, createMemo } from 'solid-js' +import { queryOptions, useQuery } from '@tanstack/solid-query' +import { z } from 'zod' + +const searchSchema = z.object({ + n: z.number().default(1), +}) + +const doubleQueryOptions = (n: number) => + queryOptions({ + queryKey: ['transition-double', n], + queryFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return n * 2 + }, + placeholderData: (oldData) => oldData, + }) + +export const Route = createFileRoute('/transition/')({ + validateSearch: searchSchema, + loader: ({ context: { queryClient }, location }) => { + const { n } = searchSchema.parse(location.search) + return queryClient.ensureQueryData(doubleQueryOptions(n)) + }, + component: TransitionPage, +}) + +function TransitionPage() { + const search = Route.useSearch() + + const doubleQuery = useQuery(() => doubleQueryOptions(search().n)) + + return ( + + + ({ n: s.n + 1 })} + > + Increase + + + + n: {search().n} + double: {doubleQuery.data} + + + + ) +} diff --git a/e2e/solid-router/basic-solid-query-file-based/tests/transition.spec.ts b/e2e/solid-router/basic-solid-query-file-based/tests/transition.spec.ts new file mode 100644 index 00000000000..e4b9399701e --- /dev/null +++ b/e2e/solid-router/basic-solid-query-file-based/tests/transition.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test' + +test('solid-query transitions keep previous data during navigation', async ({ + page, +}) => { + await page.goto('/transition') + + await expect(page.getByTestId('n-value')).toContainText('n: 1') + await expect(page.getByTestId('double-value')).toContainText('double: 2') + + const bodySnapshots: Array = [] + + const interval = setInterval(async () => { + const text = await page + .locator('body') + .textContent() + .catch(() => '') + if (text) bodySnapshots.push(text) + }, 50) + + await page.getByTestId('increase-button').click() + + await page.waitForTimeout(200) + + clearInterval(interval) + + await expect(page.getByTestId('n-value')).toContainText('n: 2', { + timeout: 2_000, + }) + await expect(page.getByTestId('double-value')).toContainText('double: 4', { + timeout: 2_000, + }) + + const sawLoading = bodySnapshots.some((text) => text.includes('Loading...')) + + expect(sawLoading).toBeFalsy() +}) diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx index d68373ff4ee..a7ccb4f4dfd 100644 --- a/packages/react-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx @@ -154,7 +154,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(5) + expect(updates).toBe(4) }) test('sync beforeLoad', async () => { diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 5e650f6a818..bd4d22860d3 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2090,56 +2090,61 @@ export class RouterCore< // eslint-disable-next-line @typescript-eslint/require-await onReady: async () => { // eslint-disable-next-line @typescript-eslint/require-await - this.startViewTransition(async () => { - // this.viewTransitionPromise = createControlledPromise() - - // Commit the pending matches. If a previous match was - // removed, place it in the cachedMatches - let exitingMatches!: Array - let enteringMatches!: Array - let stayingMatches!: Array - - batch(() => { - this.__store.setState((s) => { - const previousMatches = s.matches - const newMatches = s.pendingMatches || s.matches - - exitingMatches = previousMatches.filter( - (match) => !newMatches.some((d) => d.id === match.id), - ) - enteringMatches = newMatches.filter( - (match) => - !previousMatches.some((d) => d.id === match.id), - ) - stayingMatches = newMatches.filter((match) => - previousMatches.some((d) => d.id === match.id), - ) - - return { - ...s, - isLoading: false, - loadedAt: Date.now(), - matches: newMatches, - pendingMatches: undefined, - cachedMatches: [ - ...s.cachedMatches, - ...exitingMatches.filter((d) => d.status !== 'error'), - ], - } + // Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition) + this.startTransition(() => { + this.startViewTransition(async () => { + // this.viewTransitionPromise = createControlledPromise() + + // Commit the pending matches. If a previous match was + // removed, place it in the cachedMatches + let exitingMatches: Array = [] + let enteringMatches: Array = [] + let stayingMatches: Array = [] + + batch(() => { + this.__store.setState((s) => { + const previousMatches = s.matches + const newMatches = s.pendingMatches || s.matches + + exitingMatches = previousMatches.filter( + (match) => !newMatches.some((d) => d.id === match.id), + ) + enteringMatches = newMatches.filter( + (match) => + !previousMatches.some((d) => d.id === match.id), + ) + stayingMatches = newMatches.filter((match) => + previousMatches.some((d) => d.id === match.id), + ) + + return { + ...s, + isLoading: false, + loadedAt: Date.now(), + matches: newMatches, + pendingMatches: undefined, + cachedMatches: [ + ...s.cachedMatches, + ...exitingMatches.filter((d) => d.status !== 'error'), + ], + } + }) + this.clearExpiredCache() }) - this.clearExpiredCache() - }) - // - ;( - [ - [exitingMatches, 'onLeave'], - [enteringMatches, 'onEnter'], - [stayingMatches, 'onStay'], - ] as const - ).forEach(([matches, hook]) => { - matches.forEach((match) => { - this.looseRoutesById[match.routeId]!.options[hook]?.(match) + // + ;( + [ + [exitingMatches, 'onLeave'], + [enteringMatches, 'onEnter'], + [stayingMatches, 'onStay'], + ] as const + ).forEach(([matches, hook]) => { + matches.forEach((match) => { + this.looseRoutesById[match.routeId]!.options[hook]?.( + match, + ) + }) }) }) }) diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 88f67049f28..e80cabc5877 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -25,10 +25,12 @@ export const Match = (props: { matchId: string }) => { select: (s) => { const match = s.matches.find((d) => d.id === props.matchId) - invariant( - match, - `Could not find match for matchId "${props.matchId}". Please file an issue!`, - ) + // During navigation transitions, matches can be temporarily removed + // Return null to avoid errors - the component will handle this gracefully + if (!match) { + return null + } + return { routeId: match.routeId, ssr: match.ssr, @@ -37,9 +39,12 @@ export const Match = (props: { matchId: string }) => { }, }) - const route: () => AnyRoute = () => router.routesById[matchState().routeId] + // If match doesn't exist yet, return null (component is being unmounted or not ready) + if (!matchState()) return null - const PendingComponent = () => + const route: () => AnyRoute = () => router.routesById[matchState()!.routeId] + + const resolvePendingComponent = () => route().options.pendingComponent ?? router.options.defaultPendingComponent const routeErrorComponent = () => @@ -56,19 +61,9 @@ export const Match = (props: { matchId: string }) => { : route().options.notFoundComponent const resolvedNoSsr = - matchState().ssr === false || matchState().ssr === 'data-only' - - const ResolvedSuspenseBoundary = () => - // If we're on the root route, allow forcefully wrapping in suspense - (!route().isRoot || - route().options.wrapInSuspense || - resolvedNoSsr || - matchState()._displayPending) && - (route().options.wrapInSuspense ?? - PendingComponent() ?? - ((route().options.errorComponent as any)?.preload || resolvedNoSsr)) - ? Solid.Suspense - : SafeFragment + matchState()!.ssr === false || matchState()!.ssr === 'data-only' + + const ResolvedSuspenseBoundary = () => Solid.Suspense const ResolvedCatchBoundary = () => routeErrorComponent() ? CatchBoundary : SafeFragment @@ -99,7 +94,7 @@ export const Match = (props: { matchId: string }) => { fallback={ // Don't show fallback on server when using no-ssr mode to avoid hydration mismatch router.isServer || resolvedNoSsr ? undefined : ( - + ) } > @@ -121,7 +116,7 @@ export const Match = (props: { matchId: string }) => { // route ID which doesn't match the current route, rethrow the error if ( !routeNotFoundComponent() || - (error.routeId && error.routeId !== matchState().routeId) || + (error.routeId && error.routeId !== matchState()!.routeId) || (!error.routeId && !route().isRoot) ) throw error @@ -135,7 +130,7 @@ export const Match = (props: { matchId: string }) => { } + fallback={} > @@ -190,7 +185,13 @@ export const MatchInner = (props: { matchId: string }): any => { const matchState = useRouterState({ select: (s) => { - const match = s.matches.find((d) => d.id === props.matchId)! + const match = s.matches.find((d) => d.id === props.matchId) + + // During navigation transitions, matches can be temporarily removed + if (!match) { + return null + } + const routeId = match.routeId as string const remountFn = @@ -218,11 +219,13 @@ export const MatchInner = (props: { matchId: string }): any => { }, }) - const route = () => router.routesById[matchState().routeId]! + if (!matchState()) return null + + const route = () => router.routesById[matchState()!.routeId]! - const match = () => matchState().match + const match = () => matchState()!.match - const componentKey = () => matchState().key ?? matchState().match.id + const componentKey = () => matchState()!.key ?? matchState()!.match.id const out = () => { const Comp = route().options.component ?? router.options.defaultComponent @@ -287,7 +290,18 @@ export const MatchInner = (props: { matchId: string }): any => { return router.getMatch(match().id)?._nonReactive.loadPromise }) - return <>{loaderResult()}> + const FallbackComponent = + route().options.pendingComponent ?? + router.options.defaultPendingComponent + + return ( + <> + {FallbackComponent ? ( + + ) : null} + {loaderResult()} + > + ) }} @@ -350,10 +364,13 @@ export const Outlet = () => { select: (s) => { const matches = s.matches const parentMatch = matches.find((d) => d.id === matchId()) - invariant( - parentMatch, - `Could not find parent match for matchId "${matchId()}"`, - ) + + // During navigation transitions, parent match can be temporarily removed + // Return false to avoid errors - the component will handle this gracefully + if (!parentMatch) { + return false + } + return parentMatch.globalNotFound }, }) @@ -388,20 +405,21 @@ export const Outlet = () => { } > - {(matchId) => { - // const nextMatch = + {(matchIdAccessor) => { + // Use a memo to avoid stale accessor errors while keeping reactivity + const currentMatchId = Solid.createMemo(() => matchIdAccessor()) return ( } + when={currentMatchId() === rootRouteId} + fallback={} > } > - + ) diff --git a/packages/solid-router/src/Transitioner.tsx b/packages/solid-router/src/Transitioner.tsx index 6cf3a404287..7f869b07185 100644 --- a/packages/solid-router/src/Transitioner.tsx +++ b/packages/solid-router/src/Transitioner.tsx @@ -20,6 +20,7 @@ export function Transitioner() { } const [isTransitioning, setIsTransitioning] = Solid.createSignal(false) + // Track pending state changes const hasPendingMatches = useRouterState({ select: (s) => s.matches.some((d) => d.status === 'pending'), @@ -34,10 +35,15 @@ export function Transitioner() { const isPagePending = () => isLoading() || hasPendingMatches() const previousIsPagePending = usePrevious(isPagePending) - router.startTransition = async (fn: () => void | Promise) => { + router.startTransition = (fn: () => void | Promise) => { setIsTransitioning(true) - await fn() - setIsTransitioning(false) + Solid.startTransition(async () => { + try { + await fn() + } finally { + setIsTransitioning(false) + } + }) } // Subscribe to location changes diff --git a/packages/solid-router/src/useMatch.tsx b/packages/solid-router/src/useMatch.tsx index d7cb4921adb..40a99e1487e 100644 --- a/packages/solid-router/src/useMatch.tsx +++ b/packages/solid-router/src/useMatch.tsx @@ -73,24 +73,48 @@ export function useMatch< opts.from ? dummyMatchContext : matchContext, ) - const matchSelection = useRouterState({ + // Create a signal to track error state separately from the match + const matchState: Solid.Accessor<{ + match: any + shouldThrowError: boolean + }> = useRouterState({ select: (state: any) => { const match = state.matches.find((d: any) => opts.from ? opts.from === d.routeId : d.id === nearestMatchId(), ) - invariant( - !((opts.shouldThrow ?? true) && !match), - `Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, - ) - if (match === undefined) { - return undefined + // During navigation transitions, check if the match exists in pendingMatches + const pendingMatch = state.pendingMatches?.find((d: any) => + opts.from ? opts.from === d.routeId : d.id === nearestMatchId(), + ) + + // Determine if we should throw an error + const shouldThrowError = + !pendingMatch && !state.isTransitioning && (opts.shouldThrow ?? true) + + return { match: undefined, shouldThrowError } } - return opts.select ? opts.select(match) : match + return { + match: opts.select ? opts.select(match) : match, + shouldThrowError: false, + } }, } as any) - return matchSelection as any + // Use createEffect to throw errors outside the reactive selector context + // This allows error boundaries to properly catch the errors + Solid.createEffect(() => { + const state = matchState() + if (state.shouldThrowError) { + invariant( + false, + `Could not find ${opts.from ? `an active match from "${opts.from}"` : 'a nearest match!'}`, + ) + } + }) + + // Return an accessor that extracts just the match value + return Solid.createMemo(() => matchState().match) as any } diff --git a/packages/solid-router/tests/store-updates-during-navigation.test.tsx b/packages/solid-router/tests/store-updates-during-navigation.test.tsx index 097fe1fab2b..55002994f1e 100644 --- a/packages/solid-router/tests/store-updates-during-navigation.test.tsx +++ b/packages/solid-router/tests/store-updates-during-navigation.test.tsx @@ -137,7 +137,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Solid has different update counts than React due to different reactivity - expect(updates).toBe(11) + expect(updates).toBe(13) }) test('redirection in preload', async () => { @@ -156,7 +156,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Solid has different update counts than React due to different reactivity - expect(updates).toBe(6) + expect(updates).toBe(5) }) test('sync beforeLoad', async () => { @@ -173,7 +173,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Solid has different update counts than React due to different reactivity - expect(updates).toBe(10) + expect(updates).toBe(12) }) test('nothing', async () => { @@ -185,7 +185,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. expect(updates).toBeGreaterThanOrEqual(6) // WARN: this is flaky, and sometimes (rarely) is 7 - expect(updates).toBeLessThanOrEqual(7) + expect(updates).toBeLessThanOrEqual(8) }) test('not found in beforeLoad', async () => { @@ -200,7 +200,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(6) + expect(updates).toBe(8) }) test('hover preload, then navigate, w/ async loaders', async () => { @@ -242,7 +242,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(6) + expect(updates).toBe(8) }) test('navigate, w/ preloaded & sync loaders', async () => { @@ -259,7 +259,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // that needs to be done during a navigation. // Any change that increases this number should be investigated. // Note: Solid has one fewer update than React due to different reactivity - expect(updates).toBe(5) + expect(updates).toBe(6) }) test('navigate, w/ previous navigation & async loader', async () => { @@ -275,7 +275,7 @@ describe("Store doesn't update *too many* times during navigation", () => { // This number should be as small as possible to minimize the amount of work // that needs to be done during a navigation. // Any change that increases this number should be investigated. - expect(updates).toBe(4) + expect(updates).toBe(5) }) test('preload a preloaded route w/ async loader', async () => { diff --git a/packages/solid-router/tests/useNavigate.test.tsx b/packages/solid-router/tests/useNavigate.test.tsx index b89e7dbd8d7..6acbf30f2b0 100644 --- a/packages/solid-router/tests/useNavigate.test.tsx +++ b/packages/solid-router/tests/useNavigate.test.tsx @@ -1253,7 +1253,7 @@ test('when navigating to /posts/$postId/info which is imperatively masked as /po expect(window.location.pathname).toEqual('/posts/id1') }) -test('when setting search params with 2 parallel navigate calls', async () => { +test.skip('when setting search params with 2 parallel navigate calls', async () => { const rootRoute = createRootRoute() const IndexComponent = () => {