Skip to content

Commit 741b2ec

Browse files
committed
wrok on wrapbatch
1 parent 219b72c commit 741b2ec

File tree

5 files changed

+174
-30
lines changed

5 files changed

+174
-30
lines changed

e2e/solid-router/basic-file-based/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Route as LayoutRouteImport } from './routes/_layout'
2121
import { Route as SearchParamsRouteRouteImport } from './routes/search-params/route'
2222
import { Route as NonNestedRouteRouteImport } from './routes/non-nested/route'
2323
import { Route as IndexRouteImport } from './routes/index'
24+
import { Route as TransitionIndexRouteImport } from './routes/transition/index'
2425
import { Route as SearchParamsIndexRouteImport } from './routes/search-params/index'
2526
import { Route as RelativeIndexRouteImport } from './routes/relative/index'
2627
import { Route as RedirectIndexRouteImport } from './routes/redirect/index'
@@ -158,6 +159,11 @@ const IndexRoute = IndexRouteImport.update({
158159
path: '/',
159160
getParentRoute: () => rootRouteImport,
160161
} as any)
162+
const TransitionIndexRoute = TransitionIndexRouteImport.update({
163+
id: '/transition/',
164+
path: '/transition/',
165+
getParentRoute: () => rootRouteImport,
166+
} as any)
161167
const SearchParamsIndexRoute = SearchParamsIndexRouteImport.update({
162168
id: '/',
163169
path: '/',
@@ -600,6 +606,7 @@ export interface FileRoutesByFullPath {
600606
'/redirect': typeof RedirectIndexRoute
601607
'/relative': typeof RelativeIndexRoute
602608
'/search-params/': typeof SearchParamsIndexRoute
609+
'/transition': typeof TransitionIndexRoute
603610
'/non-nested/named/$baz': typeof NonNestedNamedBazRouteRouteWithChildren
604611
'/non-nested/path/baz': typeof NonNestedPathBazRouteRouteWithChildren
605612
'/non-nested/prefix/prefix{$baz}': typeof NonNestedPrefixPrefixChar123bazChar125RouteRouteWithChildren
@@ -684,6 +691,7 @@ export interface FileRoutesByTo {
684691
'/redirect': typeof RedirectIndexRoute
685692
'/relative': typeof RelativeIndexRoute
686693
'/search-params': typeof SearchParamsIndexRoute
694+
'/transition': typeof TransitionIndexRoute
687695
'/params-ps/named/$foo': typeof ParamsPsNamedFooRouteRouteWithChildren
688696
'/params-ps/non-nested/$foo': typeof ParamsPsNonNestedFooRouteRouteWithChildren
689697
'/insidelayout': typeof groupLayoutInsidelayoutRoute
@@ -771,6 +779,7 @@ export interface FileRoutesById {
771779
'/redirect/': typeof RedirectIndexRoute
772780
'/relative/': typeof RelativeIndexRoute
773781
'/search-params/': typeof SearchParamsIndexRoute
782+
'/transition/': typeof TransitionIndexRoute
774783
'/non-nested/named/$baz': typeof NonNestedNamedBazRouteRouteWithChildren
775784
'/non-nested/path/baz': typeof NonNestedPathBazRouteRouteWithChildren
776785
'/non-nested/prefix/prefix{$baz}': typeof NonNestedPrefixPrefixChar123bazChar125RouteRouteWithChildren
@@ -860,6 +869,7 @@ export interface FileRouteTypes {
860869
| '/redirect'
861870
| '/relative'
862871
| '/search-params/'
872+
| '/transition'
863873
| '/non-nested/named/$baz'
864874
| '/non-nested/path/baz'
865875
| '/non-nested/prefix/prefix{$baz}'
@@ -944,6 +954,7 @@ export interface FileRouteTypes {
944954
| '/redirect'
945955
| '/relative'
946956
| '/search-params'
957+
| '/transition'
947958
| '/params-ps/named/$foo'
948959
| '/params-ps/non-nested/$foo'
949960
| '/insidelayout'
@@ -1030,6 +1041,7 @@ export interface FileRouteTypes {
10301041
| '/redirect/'
10311042
| '/relative/'
10321043
| '/search-params/'
1044+
| '/transition/'
10331045
| '/non-nested/named/$baz'
10341046
| '/non-nested/path/baz'
10351047
| '/non-nested/prefix/prefix{$baz}'
@@ -1112,6 +1124,7 @@ export interface RootRouteChildren {
11121124
ParamsPsIndexRoute: typeof ParamsPsIndexRoute
11131125
RedirectIndexRoute: typeof RedirectIndexRoute
11141126
RelativeIndexRoute: typeof RelativeIndexRoute
1127+
TransitionIndexRoute: typeof TransitionIndexRoute
11151128
ParamsPsNamedFooRouteRoute: typeof ParamsPsNamedFooRouteRouteWithChildren
11161129
groupSubfolderInsideRoute: typeof groupSubfolderInsideRoute
11171130
ParamsPsNamedPrefixChar123fooChar125Route: typeof ParamsPsNamedPrefixChar123fooChar125Route
@@ -1216,6 +1229,13 @@ declare module '@tanstack/solid-router' {
12161229
preLoaderRoute: typeof IndexRouteImport
12171230
parentRoute: typeof rootRouteImport
12181231
}
1232+
'/transition/': {
1233+
id: '/transition/'
1234+
path: '/transition'
1235+
fullPath: '/transition'
1236+
preLoaderRoute: typeof TransitionIndexRouteImport
1237+
parentRoute: typeof rootRouteImport
1238+
}
12191239
'/search-params/': {
12201240
id: '/search-params/'
12211241
path: '/'
@@ -2104,6 +2124,7 @@ const rootRouteChildren: RootRouteChildren = {
21042124
ParamsPsIndexRoute: ParamsPsIndexRoute,
21052125
RedirectIndexRoute: RedirectIndexRoute,
21062126
RelativeIndexRoute: RelativeIndexRoute,
2127+
TransitionIndexRoute: TransitionIndexRoute,
21072128
ParamsPsNamedFooRouteRoute: ParamsPsNamedFooRouteRouteWithChildren,
21082129
groupSubfolderInsideRoute: groupSubfolderInsideRoute,
21092130
ParamsPsNamedPrefixChar123fooChar125Route:
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
2+
import { Link, createFileRoute } from '@tanstack/solid-router'
3+
import { Suspense, createResource } from 'solid-js'
4+
import { z } from 'zod'
5+
6+
export const Route = createFileRoute('/transition/')({
7+
validateSearch: z.object({
8+
n: z.number().default(1),
9+
}),
10+
component: Home,
11+
})
12+
13+
function Home() {
14+
return (
15+
<div class="p-2">
16+
<Link
17+
data-testid="increase-button"
18+
class="border bg-gray-50 px-3 py-1"
19+
from="/transition"
20+
search={(s) => ({ n: s.n + 1 })}
21+
startTransition
22+
>
23+
Increase
24+
</Link>
25+
26+
<Result />
27+
</div>
28+
)
29+
}
30+
31+
function Result() {
32+
const searchQuery = Route.useSearch()
33+
34+
const [doubleQuery] = createResource(
35+
() => searchQuery().n,
36+
async (n) => {
37+
await new Promise((r) => setTimeout(r, 1000))
38+
return n * 2
39+
},
40+
)
41+
42+
return (
43+
<div class="mt-2">
44+
<Suspense fallback="Loading...">
45+
<div data-testid="n-value">n: {searchQuery().n}</div>
46+
<div data-testid="double-value">double: {doubleQuery()}</div>
47+
</Suspense>
48+
</div>
49+
)
50+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect, test } from '@playwright/test'
2+
3+
test('transitions should keep old values visible during navigation', async ({
4+
page,
5+
}) => {
6+
// Navigate to the transition test route
7+
await page.goto('/transition')
8+
9+
// Wait for initial values to load
10+
await expect(page.getByTestId('n-value')).toContainText('n: 1')
11+
await expect(page.getByTestId('double-value')).toContainText('double: 2')
12+
13+
// Set up a listener to capture all text content changes
14+
const bodyTexts: Array<string> = []
15+
16+
// Poll the body text during the transition
17+
const pollInterval = setInterval(async () => {
18+
const text = await page.locator('body').textContent().catch(() => '')
19+
if (text) bodyTexts.push(text)
20+
}, 50)
21+
22+
// Click the increase button to trigger navigation with new search params
23+
await page.getByTestId('increase-button').click()
24+
25+
// Wait a bit to capture text during the transition
26+
await page.waitForTimeout(200)
27+
28+
clearInterval(pollInterval)
29+
30+
// Eventually, new values should appear
31+
await expect(page.getByTestId('n-value')).toContainText('n: 2', {
32+
timeout: 2000,
33+
})
34+
await expect(page.getByTestId('double-value')).toContainText('double: 4', {
35+
timeout: 2000,
36+
})
37+
38+
// CRITICAL TEST: Verify "Loading..." was never shown during the transition
39+
// With proper transitions, old values should remain visible until new ones arrive
40+
const hasLoadingText = bodyTexts.some((text) => text.includes('Loading...'))
41+
42+
if (hasLoadingText) {
43+
throw new Error(
44+
'FAILED: "Loading..." appeared during navigation. ' +
45+
'Solid Router should use transitions to keep old values visible.'
46+
)
47+
}
48+
})

packages/router-core/src/router.ts

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,13 @@ export class RouterCore<
938938
// router can be used in a non-react environment if necessary
939939
startTransition: StartTransitionFn = (fn) => fn()
940940

941+
/**
942+
* Can be overridden by framework implementations to wrap batch operations
943+
* in framework-specific transition APIs (e.g., Solid's startTransition).
944+
* This allows state updates to be wrapped without modifying the async flow.
945+
*/
946+
wrapBatch: (fn: () => void) => void = (fn) => fn()
947+
941948
isShell() {
942949
return !!this.options.isShell
943950
}
@@ -2099,35 +2106,38 @@ export class RouterCore<
20992106
let enteringMatches!: Array<AnyRouteMatch>
21002107
let stayingMatches!: Array<AnyRouteMatch>
21012108

2102-
batch(() => {
2103-
this.__store.setState((s) => {
2104-
const previousMatches = s.matches
2105-
const newMatches = s.pendingMatches || s.matches
2106-
2107-
exitingMatches = previousMatches.filter(
2108-
(match) => !newMatches.some((d) => d.id === match.id),
2109-
)
2110-
enteringMatches = newMatches.filter(
2111-
(match) =>
2112-
!previousMatches.some((d) => d.id === match.id),
2113-
)
2114-
stayingMatches = newMatches.filter((match) =>
2115-
previousMatches.some((d) => d.id === match.id),
2116-
)
2117-
2118-
return {
2119-
...s,
2120-
isLoading: false,
2121-
loadedAt: Date.now(),
2122-
matches: newMatches,
2123-
pendingMatches: undefined,
2124-
cachedMatches: [
2125-
...s.cachedMatches,
2126-
...exitingMatches.filter((d) => d.status !== 'error'),
2127-
],
2128-
}
2109+
// Wrap batch in framework-specific transition wrapper (e.g., Solid's startTransition)
2110+
this.wrapBatch(() => {
2111+
batch(() => {
2112+
this.__store.setState((s) => {
2113+
const previousMatches = s.matches
2114+
const newMatches = s.pendingMatches || s.matches
2115+
2116+
exitingMatches = previousMatches.filter(
2117+
(match) => !newMatches.some((d) => d.id === match.id),
2118+
)
2119+
enteringMatches = newMatches.filter(
2120+
(match) =>
2121+
!previousMatches.some((d) => d.id === match.id),
2122+
)
2123+
stayingMatches = newMatches.filter((match) =>
2124+
previousMatches.some((d) => d.id === match.id),
2125+
)
2126+
2127+
return {
2128+
...s,
2129+
isLoading: false,
2130+
loadedAt: Date.now(),
2131+
matches: newMatches,
2132+
pendingMatches: undefined,
2133+
cachedMatches: [
2134+
...s.cachedMatches,
2135+
...exitingMatches.filter((d) => d.status !== 'error'),
2136+
],
2137+
}
2138+
})
2139+
this.clearExpiredCache()
21292140
})
2130-
this.clearExpiredCache()
21312141
})
21322142

21332143
//

packages/solid-router/src/Transitioner.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function Transitioner() {
2020
}
2121

2222
const [isTransitioning, setIsTransitioning] = Solid.createSignal(false)
23+
2324
// Track pending state changes
2425
const hasPendingMatches = useRouterState({
2526
select: (s) => s.matches.some((d) => d.status === 'pending'),
@@ -34,10 +35,24 @@ export function Transitioner() {
3435
const isPagePending = () => isLoading() || hasPendingMatches()
3536
const previousIsPagePending = usePrevious(isPagePending)
3637

38+
// Wrap batch operations with Solid's startTransition
39+
// This ensures state updates that trigger re-renders happen within a transition,
40+
// which prevents Suspense boundaries from showing fallbacks immediately
41+
router.wrapBatch = (fn: () => void) => {
42+
Solid.startTransition(() => {
43+
fn()
44+
})
45+
}
46+
47+
// Track transitioning state but don't wrap the async work in startTransition
48+
// The wrapBatch hook above handles wrapping the state updates
3749
router.startTransition = async (fn: () => void | Promise<void>) => {
3850
setIsTransitioning(true)
39-
await fn()
40-
setIsTransitioning(false)
51+
try {
52+
await fn()
53+
} finally {
54+
setIsTransitioning(false)
55+
}
4156
}
4257

4358
// Subscribe to location changes

0 commit comments

Comments
 (0)