Skip to content

Commit 070647f

Browse files
ryansolidclaudecursoragent
committed
refactor(solid-router): move hydration off sharedConfig onto public Solid 2.0 APIs
Replace solid-js internals with the public hydration surface shipped in solid-js / @solidjs/web 2.0.0-rc.13 (solidjs/solid#3718): - useHydrated: sharedConfig.hydrating -> isHydrating() - serializeMatchTransfer: sharedConfig.context.serialize -> getHydrationWriter().write, gated on isHydratable() (the old context.noHydrate guard was dead in 2.0) - primeRouterFromRegistry: raw _$HY.r reads, the tsr: prefix scan and s/v unwrapping -> per-match takeHydrationValue() - tests / external-SSR repro: drop the sharedConfig reset and _$HY enumeration; the repro asserts entries server-side and consumption through takeHydrationValue Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8aa66dd commit 070647f

6 files changed

Lines changed: 48 additions & 62 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@tanstack/solid-router': patch
3+
'@tanstack/solid-router-devtools': patch
4+
'@tanstack/solid-router-ssr-query': patch
5+
'@tanstack/solid-start': patch
6+
'@tanstack/solid-start-client': patch
7+
'@tanstack/solid-start-server': patch
8+
---
9+
10+
Bump solid-js and @solidjs/web to ^2.0.0-rc.13 (and @solidjs/vite-plugin to ^3.0.0-next.46), and raise the Solid peer floors to 2.0.0-rc.13.
11+
12+
@tanstack/solid-router moves off Solid's internal `sharedConfig` and the raw `_$HY` registry onto the public hydration API rc.13 ships (solidjs/solid#3718): `useHydrated` reads `isHydrating()`; the native SSR match transfer writes through `getHydrationWriter()` gated on `isHydratable()`, so it now respects `<NoHydration>`; and the client boot takes each match's entry with `takeHydrationValue()`.

‎packages/solid-router/repro-external-ssr/entry-client.tsx‎

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { hydrate } from 'solid-js/web'
1+
import { hydrate, takeHydrationValue } from 'solid-js/web'
22
import { RouterProvider } from '../src'
33
import { createAppRouter, loaderRuns } from './app.shared'
44
import { MATCH_KEY_PREFIX } from '../src/registryTransfer'
@@ -38,23 +38,18 @@ async function main() {
3838
const root = document.getElementById('root')!
3939
const serverNode = document.getElementById('home')
4040

41-
// The registry entries the server's RouterProvider wrote, populated at
42-
// document parse — createRouter's boot consumes (and deletes) them, so
43-
// sample before construction.
44-
const hasMatchEntries = () =>
45-
Object.keys((window as any)._$HY?.r ?? {}).some((key) =>
46-
key.startsWith(MATCH_KEY_PREFIX),
47-
)
48-
results.registryHadMatchEntries = hasMatchEntries()
49-
5041
const { router, resolveAboutChunk } = createAppRouter()
5142
results.loaderRunsBeforeHydrate = loaderRuns.count
52-
// Phase 1 boot happened at router creation: entries consumed, matches
53-
// committed, zero loader runs — nothing left for the app to wire.
43+
// Phase 1 boot happened at router creation: the server's entries taken,
44+
// matches committed, zero loader runs — nothing left for the app to wire.
45+
// The boot commits only when every match took its entry, so a committed
46+
// match whose key is gone was primed from the registry.
47+
const committed = router.stores.matches.get()
5448
results.registryPrimed =
55-
results.registryHadMatchEntries &&
56-
!hasMatchEntries() &&
57-
router.stores.matches.get().length > 0
49+
committed.length > 0 &&
50+
committed.every(
51+
(match) => takeHydrationValue(MATCH_KEY_PREFIX + match.id) === undefined,
52+
)
5853

5954
results.htmlBeforeHydrate = root.innerHTML
6055
hydrate(() => <RouterProvider router={router} />, root)

‎packages/solid-router/repro-external-ssr/run.mjs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ for (const key of [
107107
'Request',
108108
'Headers',
109109
'fetch',
110+
// @solidjs/web's server-function protocol module encodes its event-stream
111+
// heartbeat at module scope; jsdom has no encoding globals.
112+
'TextEncoder',
113+
'TextDecoder',
110114
]) {
111115
if (dom.window[key] === undefined && globalThis[key] !== undefined) {
112116
dom.window[key] = globalThis[key]
@@ -135,6 +139,8 @@ if (results.pendingFlashDuringHydrate === true)
135139
failures.push('pending UI flashed during hydration')
136140
if (results.serverNodeReused !== true)
137141
failures.push('server-rendered node was NOT reused (mismatch re-render)')
142+
if (!chunks[0]?.includes('tsr:'))
143+
failures.push('server wrote no match entries into the shell chunk')
138144
if (results.registryPrimed !== true)
139145
failures.push('registry transfer did not prime the router (fell through)')
140146
if (results.loaderRunsAfterHydrate !== 0)

‎packages/solid-router/src/ClientOnly.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ let globalHydrated = false
6161

6262
export function useHydrated(): Solid.Accessor<boolean> {
6363
const [hydrated, setHydrated] = Solid.createSignal(
64-
globalHydrated && !Solid.sharedConfig.hydrating,
64+
globalHydrated && !Solid.isHydrating(),
6565
)
6666

6767
Solid.createEffect(

‎packages/solid-router/src/registryTransfer.ts‎

Lines changed: 19 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import * as Solid from 'solid-js'
1+
import { isHydratable } from 'solid-js'
2+
import { getHydrationWriter, takeHydrationValue } from '@solidjs/web'
23
import type { AnyRouteMatch, AnyRouter } from '@tanstack/router-core'
34

45
/**
@@ -9,7 +10,7 @@ import type { AnyRouteMatch, AnyRouter } from '@tanstack/router-core'
910
* derive the same key from the same URL), the identical mechanism
1011
* solid-query v6 ships query payloads through (`sq:<queryHash>`).
1112
*
12-
* Server half: while the render's serialization context is live, the
13+
* Server half: while the render takes values (`getHydrationWriter`), the
1314
* provider writes each settled match's transferable state. Client half: the
1415
* hydration-claiming boot — matching is synchronous, so the provider primes
1516
* match state from the registry and commits before rendering, without
@@ -42,17 +43,10 @@ type MatchWithBeforeLoadContext = AnyRouteMatch & {
4243
__beforeLoadContext?: Record<string, unknown>
4344
}
4445

45-
interface SerializationContext {
46-
noHydrate?: boolean
47-
serialize: (key: string, value: unknown) => void
48-
}
49-
5046
export function serializeMatchTransfer(router: AnyRouter): void {
5147
if (router.serverSsr) return
52-
const ctx = (
53-
Solid.sharedConfig as unknown as { context?: SerializationContext }
54-
).context
55-
if (!ctx || typeof ctx.serialize !== 'function' || ctx.noHydrate) return
48+
const writer = getHydrationWriter()
49+
if (!writer || !isHydratable()) return
5650

5751
for (const match of router.stores.matches.get()) {
5852
// Pending matches are skipped, not deferred: this core has no per-match
@@ -73,56 +67,37 @@ export function serializeMatchTransfer(router: AnyRouter): void {
7367
if (beforeLoadContext !== undefined)
7468
entry.beforeLoadContext = beforeLoadContext
7569
if (match.ssr !== undefined) entry.ssr = match.ssr
76-
ctx.serialize(MATCH_KEY_PREFIX + match.id, entry)
70+
writer.write(MATCH_KEY_PREFIX + match.id, entry)
7771
}
7872
}
7973

8074
/**
8175
* The hydration-claiming boot. Returns true when every synchronously
8276
* matched route found its registry entry and the matches were committed;
8377
* false falls back to the caller's existing behavior (no entries — a
84-
* non-registry server, `noHydrate`, or a pending match the server skipped).
78+
* non-registry server, `<NoHydration>`, or a pending match the server
79+
* skipped).
8580
*
86-
* Reads the raw registry rather than sharedConfig's accessors: entries
87-
* arrive as inline scripts that execute at document parse, so they are
88-
* complete before any client code runs — and the boot must commit BEFORE
89-
* the hydration render (store writes inside it are owned-scope writes).
81+
* Entries arrive as inline scripts that execute at document parse, so they
82+
* are complete before any client code runs and `takeHydrationValue` reads
83+
* them before `hydrate()` — the boot must commit BEFORE the hydration
84+
* render (store writes inside it are owned-scope writes). Each matched
85+
* route takes its own key: a page without entries (SPA, or Start's own
86+
* channel) falls through at the first match.
9087
*/
9188
export function primeRouterFromRegistry(router: AnyRouter): boolean {
9289
if (router.stores.matches.get().length > 0) return false
93-
const registry = (
94-
globalThis as unknown as { _$HY?: { r: Record<string, unknown> } }
95-
)._$HY?.r
96-
if (!registry) return false
97-
// A page with no match entries (SPA, or Start's own channel) skips before
98-
// paying for a match pass.
99-
let hasMatchEntries = false
100-
for (const key in registry) {
101-
if (key.startsWith(MATCH_KEY_PREFIX)) {
102-
hasMatchEntries = true
103-
break
104-
}
105-
}
106-
if (!hasMatchEntries) return false
10790

10891
const matches = router.matchRoutes(router.latestLocation)
10992
if (matches.length === 0) return false
11093

11194
const primed: Array<AnyRouteMatch> = []
11295
for (const match of matches) {
113-
const key = MATCH_KEY_PREFIX + match.id
114-
if (!(key in registry)) return false
115-
const raw = registry[key] as
116-
| TransferredMatch
117-
| { s: number; v?: TransferredMatch }
118-
| null
119-
delete registry[key]
120-
// Settled serialization refs are stamped `s`/`v`; sync-serialized
121-
// entries are the value itself.
122-
const entry =
123-
raw != null && typeof raw === 'object' && 's' in raw && raw.s === 1
124-
? raw.v
125-
: (raw as TransferredMatch | null)
96+
const taken = takeHydrationValue<TransferredMatch | null>(
97+
MATCH_KEY_PREFIX + match.id,
98+
)
99+
if (taken?.status !== 'resolved') return false
100+
const entry = taken.value
126101
if (!entry || typeof entry.status !== 'string') return false
127102
primed.push(applyTransferredMatch(match, entry))
128103
}

‎packages/solid-router/tests/matches-hydration-boundary.test.tsx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { afterEach, describe, expect, test, vi } from 'vitest'
2-
import { sharedConfig } from 'solid-js'
32
import { cleanup, render, screen } from '@solidjs/testing-library'
43
import { hydrate } from '@tanstack/router-core/ssr/client'
54
import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id'
@@ -13,7 +12,6 @@ import {
1312
import { lazyRouteComponent } from '../src/lazyRouteComponent'
1413

1514
afterEach(() => {
16-
sharedConfig.hydrating = false
1715
delete (window as any).$_TSR
1816
vi.restoreAllMocks()
1917
cleanup()

0 commit comments

Comments
 (0)