Skip to content

feat(solid): Solid-owned SSR transport and payload channel - #8214

Open
ryansolid wants to merge 3 commits into
TanStack:solid-router-v2-prefrom
ryansolid:feat/solid-ssr-transport-payload
Open

feat(solid): Solid-owned SSR transport and payload channel#8214
ryansolid wants to merge 3 commits into
TanStack:solid-router-v2-prefrom
ryansolid:feat/solid-ssr-transport-payload

Conversation

@ryansolid

@ryansolid ryansolid commented Sep 2, 2026

Copy link
Copy Markdown

Summary

Phase 2 of the Solid-native SSR plan (RFC updated in the diff). Moves the Solid adapter's SSR stream transport and dehydration payload onto Solid's own channels — with zero diff outside the Solid packages. router-core, start-client-core, react-router, vue-router, and their start packages are byte-for-byte untouched:

git diff origin/solid-router-v2-pre..HEAD --stat -- packages
# 12 files changed — all under packages/solid-router and packages/solid-start-client
  • Transport (2a)renderRouterToStream no longer pipes through transformReadableStreamWithRouter (the per-chunk decode + </body> scan + splice transform). Router scripts write directly to the response sink as the serializer emits them; the script barrier lifts once the chunk carrying <Scripts /> has been written (chunks are scanned only until that marker is seen); the response closes when both the render completed and serialization finished, preserving the transform's 60s timeout and abort/cleanup semantics. Zero per-chunk decode/scan/splice on the hot path.
  • Payload (2b) — the DehydratedRouter rides Solid's eval-free JSON record codec (the same createJSONSerializer/createJSONDataTable infrastructure Start's server functions use) as __TSR_P queue pushes, instead of the $_TSR script channel. The rendered HTML carries no $_TSR bootstrap, no $R cross-reference header, and no parse-time eval — every router script is an inert JSON data push. This also removes the parse-time wall that blocked registry-addressed transfer: adapter-typed values decode at runtime through makeSerovalPlugin-wrapped adapters rather than evaluating $_TSR.t.get(key)(...) calls before implementations exist.

How it stays out of core

Everything rides Solid-side overrides of the framework-only router.serverSsr members, installed through the existing onServerSsrAttach lifecycle:

  • Attach seam — the Solid Router constructor registers an onServerSsrAttach listener that resolves an installer through a slot (solidSsrTransferSlot) filled by the ssr/server entry module. The encode half of Solid's codec is server-only and must never enter the client module graph; the package sideEffects allowlist keeps bundlers from dropping the slot fill. Unfilled slot ⇒ core's script channel runs completely unchanged.
  • Server (installSolidSsrTransfer) — replaces serverSsr.dehydrate with a Solid implementation that builds the DehydratedRouter the way core does (rendered matches, isShell slicing, options.dehydrate() data, manifest derived from the public router.ssr.manifest getter) and serializes it through the JSON codec into a Solid-owned script buffer. takeBufferedScripts/liftScriptBarrier are overridden to serve that buffer with core's exact shell-inline tag shape, so <Scripts /> inlining and barrier deferral work unchanged. isDehydrated/isSerializationFinished/onSerializationFinished answer from Solid-side state; setRenderFinished/cleanup wrap the originals. Core's seeded $R header and $_TSR bootstrap are drained and discarded at attach.
  • Client (synthetic $_TSR) — core hydrate() is unchanged; it still reads window.$_TSR. Solid installs a synthetic $_TSR whose lazy router getter decodes the __TSR_P queue (push-hooked so late records settle streamed loaderData promises), applied by RouterClient and Solid's hydrateStart. The decode module is loaded through a dynamic import so it shares the chunk Solid's web runtime already lazy-loads (a static import merges it into the client entry chunk, which then registers as a dynamic-import target and loses its isEntry flag to the Solid vite plugin's lazy-entry normalization — breaking Start's manifest capture for apps on the default client entry); the shim's install returns a promise the callers await before core hydrate. The synthetic's h() deletes the global once hydration completes — it has no post-hydration role since late records ride the queue's hooked push, never $_TSR.p — so typeof window.$_TSR === 'undefined' stays a valid hydration-finished probe on both channels (the basic-auth e2e suite relies on it). The shim defers to a real $_TSR bootstrap, so documents rendered by a script-channel server still hydrate.

The unexported core pieces mirrored on the Solid side (dehydrateMatch, ScriptBuffer + barrier tag shape, the inline-CSS placeholder) are marked as mirrors in routerPayloadServer.ts. A follow-up PR against main will add the small core hooks (framework dehydrated-router hand-off, script enqueue, serialization-finished signal) that let these overrides and mirrors collapse into supported seams — this PR deliberately does not depend on it, since core releases on a different cadence than this pre-release branch.

Verification

All on this branch after the rebase onto solid-router-v2-pre (post #8192/#8213 merge):

  • @tanstack/solid-router unit tests: 865 passed | 2 skipped across 59 files, zero type errors (jsdom + server-mode + typecheck).
  • Bare-pairing harness (packages/solid-router/repro-external-ssr, real server/client bundles, protocol-less SSR → hydration in jsdom): PASS — clean hydration, zero console errors/warnings, no pending flash, post-hydration nav works, streamed loaderData chunk-order assertions hold.
  • Ten Solid Start e2e suites, fresh builds: basic (80), server-functions (29), deferred-hydration (15), selective-ssr (11), scroll-restoration (10), basic-auth (7), basic-solid-query (6), serialization-adapters (5), server-routes (2), spa-mode (2) — 167 passed covering streaming order, selective-SSR lanes, adapter decode through the shim, scroll scripts, shell mode, and auth/redirect flows with the hydration-probe semantics.
  • Default-entry app builds (the chunk-graph shape the e2e apps' custom entries don't exercise): examples/solid/start-large, examples/solid/start-basic-authjs, and @benchmarks/bundle-size all build clean.
  • Rendered-HTML spot check: SSR output carries __TSR_P records only — zero $_TSR occurrences.

Notes for reviewers

  • The serverSsr overrides install at attachRouterServerSsrUtils time via onServerSsrAttach, i.e. before any dehydrate/injection call site can run — but the ordering is implicit. The core-hooks PR will make it explicit.
  • Core's internal isDehydrated/serialization flags never advance in this mode (core's dehydrate body never runs); anything inside core that consulted them directly rather than through the serverSsr surface would misread. Nothing does today — flagging it as the kind of coupling the core hooks should eliminate.
  • Benchmarks: the SSR scenarios' dehydration-marker detection now accepts either channel's marker ($_TSR or __TSR_P), so CodSpeed stays meaningful across frameworks.
  • The decode-chunk hazard above is worth knowing about generally: any package that statically imports @solidjs/web/serialization/decode into a chunk Solid's runtime also dynamically imports will strip the client entry's isEntry under the current vite-plugin normalization. The core-hooks PR won't change this — it's a bundler-graph property — but it's now documented in the RFC.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 5df252e8-6aec-48d3-969d-cabc0c8d101d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ryansolid
ryansolid force-pushed the feat/solid-ssr-transport-payload branch 3 times, most recently from bfed342 to aab6864 Compare September 2, 2026 21:23
@ryansolid
ryansolid marked this pull request as ready for review September 2, 2026 21:24
@nx-cloud

nx-cloud Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 86203b8

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ❌ Failed 7m 2s View ↗
nx run-many --target=build --exclude=examples/*... ❌ Failed 1m 54s View ↗

☁️ Nx Cloud last updated this comment at 2026-09-03 02:52:35 UTC

@ryansolid
ryansolid force-pushed the feat/solid-ssr-transport-payload branch from aab6864 to 81a9cc6 Compare September 2, 2026 22:15
@codspeed-hq

codspeed-hq Bot commented Sep 2, 2026

Copy link
Copy Markdown

Hooray! CodSpeed harness just leveled up!

The base and head of this comparison were measured with different runner settings, so their benchmark values are not directly comparable.

What changed between base and head:

Re-run the base with the same settings to get a valid performance comparison.


Comparing ryansolid:feat/solid-ssr-transport-payload (86203b8) with solid-router-v2-pre (67a9040)1

Open in CodSpeed

Footnotes

  1. No successful run was found on solid-router-v2-pre (b445b89) during the generation of this report, so 67a9040 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@brenelz
brenelz marked this pull request as draft September 2, 2026 23:12
@brenelz

brenelz commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Yeah we should see if we can do this without modifying core packages. We don't have good versioning for changes to those right now until we are on the main branch

@ryansolid
ryansolid force-pushed the feat/solid-ssr-transport-payload branch from 7958328 to 86203b8 Compare September 3, 2026 02:07
@ryansolid
ryansolid marked this pull request as ready for review September 3, 2026 04:01
ryansolid and others added 3 commits September 2, 2026 22:01
…(Phase 2a+2b, zero core diff)

Phase 2a — the Solid path drops transformStreamWithRouter: router scripts
ride the response writer directly in renderRouterToStream (Solid-owned
sink, script-barrier scanning until the <Scripts /> chunk is written,
doctype handling, 60s serialization timeout, abort/cleanup semantics).

Phase 2b — the DehydratedRouter rides Solid's eval-free JSON codec
(__TSR_P record queue) instead of the $_TSR script channel, implemented
entirely as Solid-side overrides of the framework-only serverSsr members
(installed via the onServerSsrAttach lifecycle):

- serverSsr.dehydrate is replaced with a Solid implementation mirroring
  core's (rendered matches, isShell slicing, options.dehydrate() data,
  manifest derived from the public router.ssr.manifest getter) that
  serializes through createJSONSerializer into a Solid-owned script
  buffer; takeBufferedScripts/liftScriptBarrier serve it with core's
  shell-inline tag shape, so <Scripts /> and the 2a sink are unchanged.
- isDehydrated/isSerializationFinished/onSerializationFinished answer
  from Solid-side state; setRenderFinished/cleanup wrap the originals.
- Core's seeded $R scope header and $_TSR bootstrap are drained and
  discarded at attach — the SSR HTML carries no executable payload
  scripts and no parse-time eval.
- The client installs a synthetic window.$_TSR whose lazy router getter
  decodes the record queue (createJSONDataTable, push-hooked for
  streamed late records), so core hydrate() is byte-for-byte unchanged.
  The shim defers to a real $_TSR bootstrap (older-server documents).
- The installer is resolved through solidSsrTransferSlot, filled by the
  ssr/server entry (sideEffects allowlist keeps the fill alive): the
  encode half of Solid's codec must never enter the client module graph.

Zero diff to router-core, start-client-core, react-router, vue-router,
and their start packages. A follow-up core-hooks PR against main will
collapse the overrides into supported seams; the unexported core pieces
mirrored here (dehydrateMatch, ScriptBuffer, barrier id, inline-css
placeholder) are marked as such in routerPayloadServer.

Co-authored-by: Cursor <cursoragent@cursor.com>
…h zero core diff

Records the shipped 2a/2b architecture: serverSsr member overrides via
onServerSsrAttach, the slot-armed installer, the manifest derivation from
the public getter, the synthetic $_TSR client shim, and the follow-up
core-hooks PR against main. Marks the resolved open questions (deferred
loaderData streaming, script-channel consumer inventory) and the
nine-suite regression gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
The SSR scenarios asserted the $_TSR bootstrap to locate the dehydration
payload in rendered HTML; Solid's channel now emits __TSR_P records
instead. findDehydrationMarkerIndex accepts either marker so the
benchmarks stay meaningful across both channels.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants