Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions e2e/solid-router/basic-file-based/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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: '/',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}'
Expand Down Expand Up @@ -944,6 +954,7 @@ export interface FileRouteTypes {
| '/redirect'
| '/relative'
| '/search-params'
| '/transition'
| '/params-ps/named/$foo'
| '/params-ps/non-nested/$foo'
| '/insidelayout'
Expand Down Expand Up @@ -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}'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: '/'
Expand Down Expand Up @@ -2104,6 +2124,7 @@ const rootRouteChildren: RootRouteChildren = {
ParamsPsIndexRoute: ParamsPsIndexRoute,
RedirectIndexRoute: RedirectIndexRoute,
RelativeIndexRoute: RelativeIndexRoute,
TransitionIndexRoute: TransitionIndexRoute,
ParamsPsNamedFooRouteRoute: ParamsPsNamedFooRouteRouteWithChildren,
groupSubfolderInsideRoute: groupSubfolderInsideRoute,
ParamsPsNamedPrefixChar123fooChar125Route:
Expand Down
48 changes: 48 additions & 0 deletions e2e/solid-router/basic-file-based/src/routes/transition/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div class="p-2">
<Link
data-testid="increase-button"
class="border bg-gray-50 px-3 py-1"
from="/transition"
search={(s) => ({ n: s.n + 1 })}
>
Increase
</Link>

<Result />
</div>
)
}

function Result() {
const searchQuery = Route.useSearch()

const [doubleQuery] = createResource(
() => searchQuery().n,
async (n) => {
await new Promise((r) => setTimeout(r, 1000))
return n * 2
},
)

return (
<div class="mt-2">
<Suspense fallback="Loading...">
<div data-testid="n-value">n: {searchQuery().n}</div>
<div data-testid="double-value">double: {doubleQuery()}</div>
</Suspense>
</div>
)
}
43 changes: 43 additions & 0 deletions e2e/solid-router/basic-file-based/tests/transition.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string> = []

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.',
)
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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: '/',
Expand Down Expand Up @@ -62,13 +68,15 @@ export interface FileRoutesByFullPath {
'/posts': typeof PostsRouteWithChildren
'/posts/$postId': typeof PostsPostIdRoute
'/posts/': typeof PostsIndexRoute
'/transition': typeof TransitionIndexRoute
'/layout-a': typeof LayoutLayout2LayoutARoute
'/layout-b': typeof LayoutLayout2LayoutBRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/posts/$postId': typeof PostsPostIdRoute
'/posts': typeof PostsIndexRoute
'/transition': typeof TransitionIndexRoute
'/layout-a': typeof LayoutLayout2LayoutARoute
'/layout-b': typeof LayoutLayout2LayoutBRoute
}
Expand All @@ -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
}
Expand All @@ -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__'
| '/'
Expand All @@ -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
Expand All @@ -110,6 +127,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LayoutRoute: typeof LayoutRouteWithChildren
PostsRoute: typeof PostsRouteWithChildren
TransitionIndexRoute: typeof TransitionIndexRoute
}

declare module '@tanstack/solid-router' {
Expand All @@ -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: '/'
Expand Down Expand Up @@ -214,6 +239,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LayoutRoute: LayoutRouteWithChildren,
PostsRoute: PostsRouteWithChildren,
TransitionIndexRoute: TransitionIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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 = createMemo(() =>
useQuery(() => doubleQueryOptions(search().n)),
)

return (
<Suspense fallback="Loading...">
<div class="p-2">
<Link
data-testid="increase-button"
class="border bg-gray-50 px-3 py-1"
from="/transition"
search={(s) => ({ n: s.n + 1 })}
>
Increase
</Link>

<div class="mt-2">
<div data-testid="n-value">n: {search().n}</div>
<div data-testid="double-value">double: {doubleQuery().data}</div>
</div>
</div>
</Suspense>
)
}
Original file line number Diff line number Diff line change
@@ -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<string> = []

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)
Comment on lines +13 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add cleanup for interval to prevent resource leak.

If the test fails or times out before line 25, setInterval will continue running. Wrap the interval logic in a try-finally block or use Playwright's test.afterEach for cleanup.

+  let interval: NodeJS.Timeout | undefined
+
-  const interval = setInterval(async () => {
+  try {
+    interval = setInterval(async () => {
+      const text = await page
+        .locator('body')
+        .textContent()
+        .catch(() => '')
+      if (text) bodySnapshots.push(text)
+    }, 50)
+
+    await page.getByTestId('increase-button').click()
+
+    await expect(page.getByTestId('n-value')).toContainText('n: 2', {
+      timeout: 2_000,
+    })
+    await expect(page.getByTestId('double-value')).toContainText('double: 4', {
+      timeout: 2_000,
+    })
+  } finally {
+    if (interval) clearInterval(interval)
+  }
-    const text = await page
-      .locator('body')
-      .textContent()
-      .catch(() => '')
-    if (text) bodySnapshots.push(text)
-  }, 50)
-
-  await page.getByTestId('increase-button').click()
-
-  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,
-  })

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In e2e/solid-router/basic-solid-query-file-based/tests/transition.spec.ts around
lines 13 to 25, the setInterval created to poll page body content is not
guaranteed to be cleared if the test fails or times out; wrap the interval logic
so it is always cleaned up—for example, create the interval, run the async test
actions inside a try block, and call clearInterval(interval) in a finally block,
or register clearInterval with Playwright's test.afterEach cleanup (or
test.addCleanup) so the interval is always cleared even on failure/timeouts.


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()
Comment on lines +11 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical flaw in test timing logic.

The interval stops capturing snapshots at 200ms (line 25), but the expectations wait up to 2 seconds for updated values (lines 27-32). If the "Loading..." fallback appears between 200ms and when the values actually update, the test won't detect it—creating a false positive.

Move clearInterval(interval) to after the final expectations to ensure the entire transition period is monitored:

-  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,
   })
+
+  clearInterval(interval)
 
   const sawLoading = bodySnapshots.some((text) => text.includes('Loading...'))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const bodySnapshots: Array<string> = []
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()
const bodySnapshots: Array<string> = []
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 expect(page.getByTestId('n-value')).toContainText('n: 2', {
timeout: 2_000,
})
await expect(page.getByTestId('double-value')).toContainText('double: 4', {
timeout: 2_000,
})
clearInterval(interval)
const sawLoading = bodySnapshots.some((text) => text.includes('Loading...'))
expect(sawLoading).toBeFalsy()
🤖 Prompt for AI Agents
In e2e/solid-router/basic-solid-query-file-based/tests/transition.spec.ts around
lines 11 to 36, the snapshot-capturing interval is cleared before the test waits
(and retries) for the final values, so any transient "Loading..." state that
occurs after 200ms but before the expectations resolve will be missed; move
clearInterval(interval) to after the final expect checks (i.e., after the two
toContainText awaits and just before asserting sawLoading) so the interval
continues recording for the entire timeout window.

})
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading