Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions .changeset/bump-solid-rc-6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/solid-router': patch
'@tanstack/solid-router-devtools': patch
'@tanstack/solid-router-ssr-query': patch
'@tanstack/solid-start': patch
'@tanstack/solid-start-client': patch
'@tanstack/solid-start-server': patch
---

Bump solid-js, @solidjs/web, and @solidjs/signals to ^2.0.0-rc.6 across the monorepo. rc.6 provides the named flight-data source API (registerFlightDataSource / two-argument subscribeFlightData) that the Start single-flight integration now requires; @tanstack/solid-start's peer floor moves to rc.6 accordingly.
5 changes: 5 additions & 0 deletions .changeset/load-flight-target.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-router': patch
---

New `loadFlightTarget` helper (exported from `@tanstack/solid-router/ssr/server`): the router's half of a single-flight refresh. Given the mutation request's target href, it builds a router for that location, loads it, and returns the dehydrated payload for the `tsr` flight-data slice, so server collectors can refresh router state alongside other caches on the same mutation response.
5 changes: 5 additions & 0 deletions .changeset/named-flight-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-start': patch
---

Adopt Solid's multi-source single-flight protocol: the router's flight data (loader/match state, dehydrated data) registers under its own source id (`tsr`), so other caches' slices — e.g. solid-query's `sq` — coexist on the same mutation response instead of competing for a single consumer slot, and a user-supplied `collectFlightData` hook adds data alongside the router's rather than displacing it. Requires solid-js / @solidjs/web 2.0.0-rc.6+ (named flight-data sources).
5 changes: 5 additions & 0 deletions .changeset/retire-solid-router-ssr-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-router-ssr-query': patch
---

Deprecated: Solid Query's `QueryClientProvider` now carries the Router + Query SSR pairing natively (registry hydration, named single-flight sources, redirect handling via userland glue), so this integration package is no longer needed. See the README for the migration.
5 changes: 5 additions & 0 deletions .changeset/solid-native-ssr-transfer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/solid-router': patch
---

Native SSR state transfer for Solid: router match state (loaderData, beforeLoad context, status, errors) now rides Solid's hydration registry under `tsr:` keys instead of a bespoke bootstrap script, deferred `loaderData` promises stream natively via seroval, the client primes router state from the registry in the Router constructor (before any render context, eliminating boot-time refetches and `bootLoad`-style workarounds), and `RouterProvider` owns the server-side `router.load()` dispatch so server entries no longer await it manually.
138 changes: 138 additions & 0 deletions RFC-solid-native-ssr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# RFC: Solid-native SSR/hydration for `@tanstack/solid-router`

**Status:** draft for discussion
**Scope:** the Solid adapter's SSR transfer and hydration boot — not the cross-framework router API

## Context

The Solid v2 line has been converging on a pattern, one PR at a time: every
mechanism the router carried for moving state across the wire has a Solid
2.0 channel that does it natively, and every replacement so far has shrunk
code and payload while keeping every e2e suite green.

- SSR query transfer → the hydration registry, content-addressed
(`solid-query` v6; `solid-router-ssr-query` retired in
[#8193](https://github.com/TanStack/router/pull/8193), which removed a
double-ship of every query payload)
- Post-mutation data → the multi-source single-flight envelope
([#8192](https://github.com/TanStack/router/pull/8192): named sources +
`loadFlightTarget`)
- Redirects from cache-driven fetches → a dozen lines composing
`isRedirect`/`resolveRedirect` with the caches' `config.onError`
- The reference for all of it: solidjs/templates `fullstack-tanstack`
(bare vite + Router + Query, no Start, no integration package —
[templates#287](https://github.com/solidjs/templates/pull/287))

What remains are the deeper assumptions, inherited from the React
integration model where the framework has no serialization channel and no
hydration-claiming, so the router had to build both:

1. **The router owns the SSR envelope.** `router.serverSsr`, the
`dehydrate()`/`hydrate()` options, and the `__TSR_SSR__` script protocol
form a parallel serialization channel through the router's own stream
injection.
2. **The router loads before hydration.** The client is expected to
resolve and commit matches before first render (the template's
`bootLoad`, plus the prefetch-pausing it drags along) because React
hydration can't claim through async work.
3. **The router owns the HTML's boundary structure.** A global catch
boundary is injected client-side that the server never rendered —
correct when TanStack's stream handlers produce the HTML, a hydration
mismatch when Solid's renderer does (today's escape hatch:
`disableGlobalCatchBoundary: true`).
4. **Suspense-era data ergonomics.** Deferred/`Await` patterns exist
because components couldn't just read async values. In Solid 2 they
can; the idiomatic usage is already "loaders as non-blocking prefetch
hints, suspension at the read point."

## Principles

- **The framework channel is the transfer.** Anything Solid can serialize
through the hydration registry (promise-valued, streamed by seroval as
it settles) should not ride a router-owned side channel.
- **Never block up front.** No pre-hydration load pass, no up-front chunk
resolution, no envelope waiting. Solid's `lazy` already participates in
hydration — a route chunk resolves under its boundary and claims its
markup when it lands — so `route.lazy` maps onto read-point resolution.
The same goes for data: pending loaders transfer as promises and settle
where they're read.
- **Loader API semantics are untouched.** Blocking loaders,
`pendingComponent`, `beforeLoad`, deferred — the cross-framework
contract stays. This RFC changes how the _adapter_ transfers and boots,
not what the API promises.
- **Data lives in caches that know how to transfer themselves.** The
router transfers _its_ state (matches, `loaderData`, statuses); query
caches transfer theirs; the flight envelope keys them independently.
Nothing aggregates someone else's state.

## Phase 1 — the bare pairing goes fully native (no Start exposure)

The template path (`createRouter` + `RouterProvider` under Solid's
renderer) never enters `createStartHandler`, so it can change freely.

- **Registry match transfer.** _(Landed: `registryTransfer.ts`,
serialization in `RouterProvider`.)_ During server render the adapter
serializes each settled match's state content-addressed
(`tsr:<matchId>` keys) — the pattern `solid-query`'s provider proved.
Still open within this bullet: promise-valued entries for matches
pending at render time (streaming SSR). This core has no per-match
settle promise, so it needs a dispatch-time hook; today pending matches
are skipped and the client boot falls through to current behavior.
- **Hydration-claiming boot.** _(Landed: the `Router` constructor.)_
Match synchronously, prime match state from the registry, commit
without running loaders. Placement discovered to be load-bearing:
committing inside the hydration render desyncs the claiming walk's
registry bookkeeping even with writes moved off the owner — router
creation is the client's natural pre-render moment (after document
parse, before `hydrate()`). Route chunks resolve at the read point via
Solid `lazy` semantics. This deleted the template's `bootLoad` (and its
URL-divergence reload guard) and the prefetch-pausing flag outright —
verified on the production template: zero server-function requests at
boot, hydration clean, single-flight unchanged.
- **Boundary parity.** _(Already true on this line.)_ The adapter's
boundary structure is symmetric between server and client
(`_resolveMatchesLoadingBoundary` consults no hydration state), so
`disableGlobalCatchBoundary` is no longer a parity workaround — it
survives as a semantic choice: let errors (including SSR-thrown
`redirect()`) bubble past the router to app-owned boundaries and the
stream handler. The template's comment was corrected to say so.

## Phase 2 — Start rides it behind the existing contract

`start-server-core` orchestrates through `attachRouterServerSsrUtils`,
`dehydrate()`/`hydrate()`, and the stream handler — that contract is
shared core and stays intact as a facade. Within it, the Solid
`defaultStreamHandler`/`renderRouterToStream` source the transfer from the
registry channel instead of `__TSR_SSR__` script injection wherever both
exist; user `dehydrate`/`hydrate` hooks keep working. The flight collector
already went through this door: `loadFlightTarget` absorbed the
event-derivation half, and its extraction half shrinks further once match
state is registry-addressed.

**Regression gate:** the three Solid Start e2e suites
(`basic-solid-query`, `server-functions`, `server-routes` — 37 tests,
including redirect-from-query on both mount and SSR paths, and the
transition semantics) all run locally today and define "didn't break
Start."

## Phase 3 — upstream

Once both runs are proven, propose the defaults upstream with working
code and measured deltas (payload double-ship eliminated, boot code
deleted, boundary workaround gone), the same argument shape as #8193 one
layer deeper. React/Vue adapters are untouched throughout;
`router-ssr-query-core`'s transport remains correct for frameworks
without a native channel.

## Open questions

- Match key identity: route id + params hash vs match id — needs to be
stable across server/client and across redirects into the same route.
- `loaderData` streaming semantics vs the existing deferred API: a
promise-valued registry entry makes deferred _transfer_ free, but the
read-side API compatibility needs mapping.
- Scroll restoration and `__TSR_SSR__` consumers beyond match state
(manifest/asset injection) — inventory what else rides the script
channel before swapping it.
- Where the SSR teardown lands for router state (the query cache got
cancel+clear on render disposal; matches may want the same).
4 changes: 2 additions & 2 deletions benchmarks/bundle-size/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
}
},
"dependencies": {
"@solidjs/web": "^2.0.0-rc.4",
"@solidjs/web": "^2.0.0-rc.6",
"@tanstack/react-router": "workspace:^",
"@tanstack/react-start": "workspace:^",
"@tanstack/solid-router": "workspace:^",
Expand All @@ -45,7 +45,7 @@
"@tanstack/vue-start": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"solid-js": "^2.0.0-rc.4",
"solid-js": "^2.0.0-rc.6",
"vue": "^3.5.16"
},
"devDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/client-nav/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@
"test:types:vue": "tsc -p ./vue/tsconfig.json --noEmit"
},
"dependencies": {
"@solidjs/web": "^2.0.0-rc.4",
"@solidjs/web": "^2.0.0-rc.6",
"@tanstack/history": "workspace:^",
"@tanstack/react-router": "workspace:^",
"@tanstack/router-core": "workspace:^",
"@tanstack/solid-router": "workspace:^",
"@tanstack/vue-router": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"solid-js": "^2.0.0-rc.4",
"solid-js": "^2.0.0-rc.6",
"vue": "^3.5.16"
},
"devDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/memory/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@
"#memory-client/lifecycle": "./lifecycle.ts"
},
"dependencies": {
"@solidjs/web": "^2.0.0-rc.4",
"@solidjs/web": "^2.0.0-rc.6",
"@tanstack/react-router": "workspace:*",
"@tanstack/router-core": "workspace:*",
"@tanstack/solid-router": "workspace:*",
"@tanstack/vue-router": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"solid-js": "^2.0.0-rc.4",
"solid-js": "^2.0.0-rc.6",
"vue": "^3.5.16"
},
"devDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/memory/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"#memory-server/flame-runner": "./flame-runner.ts"
},
"dependencies": {
"@solidjs/web": "^2.0.0-rc.4",
"@solidjs/web": "^2.0.0-rc.6",
"@tanstack/react-router": "workspace:*",
"@tanstack/react-start": "workspace:*",
"@tanstack/solid-router": "workspace:*",
Expand All @@ -20,7 +20,7 @@
"@tanstack/vue-start": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"solid-js": "^2.0.0-rc.4",
"solid-js": "^2.0.0-rc.6",
"vue": "^3.5.16"
},
"devDependencies": {
Expand Down
12 changes: 8 additions & 4 deletions benchmarks/solid-server-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ const instanceHeader = 'X-Server-Function-Instance'
export const solidServerFunctionFormatHeader = 'X-Server-Function-Format'

// Since solid-js 2.0.0-rc.4 the server resolves the function id from the
// request url pathname (`<endpoint>/<id>`); the X-Server-Function-Id header
// no longer exists. Requests keep the full function url they were given.
// request url pathname; the X-Server-Function-Id header no longer exists.
// Since 2.0.0-rc.6 scripted callers must use the data address
// (`<endpoint>/data/<id>`) — the bare address (`<endpoint>/<id>`) answers
// document traffic with the no-JS convention instead of the wire protocol.
function resolveSolidServerFunctionPathname(url: string) {
const parsed = new URL(url, origin)
const id = parsed.pathname.slice(parsed.pathname.lastIndexOf('/') + 1)
const slash = parsed.pathname.lastIndexOf('/')
const id = parsed.pathname.slice(slash + 1)

if (!id) {
throw new Error(`Unable to resolve Solid server function id from ${url}`)
}

return parsed.pathname
const mount = parsed.pathname.slice(0, slash)
return mount.endsWith('/data') ? parsed.pathname : `${mount}/data/${id}`
}

function createSolidServerFunctionHeaders(instance: string) {
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"type": "module",
"dependencies": {
"@solidjs/web": "^2.0.0-rc.4",
"@solidjs/web": "^2.0.0-rc.6",
"@tanstack/react-router": "workspace:^",
"@tanstack/react-start": "workspace:^",
"@tanstack/solid-router": "workspace:^",
Expand All @@ -12,7 +12,7 @@
"@tanstack/vue-start": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"solid-js": "^2.0.0-rc.4",
"solid-js": "^2.0.0-rc.6",
"vue": "^3.5.16"
},
"devDependencies": {
Expand Down
Loading
Loading