Skip to content
Draft
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
123 changes: 123 additions & 0 deletions RESULT-perf-task3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# RESULT — perf/task3-ssr-manifest-cache

## Goal

`packages/router-core/src/ssr/ssr-server.ts` `dehydrate()` re-serializes the static
route manifest (matched-route asset descriptors) through seroval on **every**
request, although it is byte-identical for a given matched-route set. This work
caches the _serialized_ manifest fragment in an LRU and emits it as a separate
script assignment so only dynamic data goes through the per-request stream.

## Cross-reference analysis (task 1) — why splitting is safe here

Seroval's `crossSerializeStream` builds one reference-ID graph (`$R["tsr"]`)
per stream call and deduplicates shared object identity inside that graph.
Naive splitting would be unsafe **only if** objects were shared across the
manifest/match boundary — then one side would reference an ID the other side
never defines, breaking hydration.

Analysis of the data:

- The manifest fragment (`preparedManifest.routes`, `scriptFormat`,
inline-CSS placeholder) is produced by the bundler plugin at build time:
plain JSON-safe data (strings, arrays, plain objects, booleans).
- The per-request part (`matches` → `dehydrateMatch()`: loaderData,
beforeLoadContext, errors; plus optional request-scoped assets) is created
independently at runtime by loaders/user code.
- No code path assigns a manifest route object into match data or vice versa;
the two trees never share references (request assets are merged via
`{...spread}` copies into a fresh root-route object,
`mergeRequestAssetsIntoRootRoute`).

Conclusion: no cross-references exist between the two portions, so splitting is
hydration-safe. Additionally, the split halves are emitted into the _same_
inline `<script>` block in order (initial `$_TSR.router=` chunk →
`$_TSR.router.manifest=` assignment → streamed continuations → `$_TSR.e()`),
and client hydration (`hydrate()` in `load-client.ts`) reads
`$_TSR.router.manifest` only after all scripts have executed, so replay order
is preserved.

## Design (task 2/3)

- New LRU (`createLRUCache`, size 100, WeakMap-keyed per `ServerManifest`)
caches a `SerializedManifestFragment { head, routes, tail }` keyed by the
existing matched-route-id cache key (`getMatchedRoutesCacheKey`).
- `head/tail` wrap `"routes":` including any static `scriptFormat` /
`inlineStyle` placeholder entries; `routes` is seroval
`serialize(preparedRoutes, { plugins })` — the same plugin set as the main
stream, so custom serialization adapters still apply.
- On cache hit with no request-scoped assets, `dehydrate()` emits exactly one
pre-built string:
`$_TSR.router.manifest=<head><routes><tail>`
and passes `manifest: undefined` into `crossSerializeStream`.
- With request-scoped assets, the merged root route is serialized per request
(small) and spliced in without touching the cached bytes:
`$_TSR.router.manifest=<head>Object.assign({},<routes>,{"__root__":<merged>})<tail>`
- The assignment script is enqueued inside `onSerialize(initial=true)`
immediately after the initial router chunk — `ScriptBuffer` preserves order,
so it can never run before `$_TSR.router` exists nor after `.e()`.
- Gated to production (`isManifestSerializationCacheEnabled()` reads env lazily)
to avoid stale fragments under dev HMR — same policy as the existing
prepared-manifest LRU.
- Failure fallbacks: if fragment or merged-root serialization throws, the code
logs and falls back to embedding the full manifest in the stream graph
(exact previous behavior). Escaping/safety is unchanged: output strings are
seroval-produced expressions embedded in `<script>` exactly as before.

## Tests (task 4)

New file `packages/router-core/tests/ssr-server-manifest-cache.test.ts`:

1. Second request with same matched-route set produces identical parsed
hydration result and byte-identical cached manifest fragment.
2. Manifest is emitted as a separate `$_TSR.router.manifest=` assignment
ordered between the `$_TSR.router=` chunk and `$_TSR.e()`, and evaluates to
the correct routes.
3. Cache-hit hydration result deep-equals the uncached path result
(manifest + matches).
4. Request-scoped assets merge correctly over a cached fragment, repeatedly.
5. 2×50 distinct route sets (> LRU size 100 total insertions) force eviction;
evicted-and-recomputed requests stay correct.
6. Direct LRU boundedness/eviction-order unit test.

All 107 test files / 1612 tests pass (`test:unit`), plus `test:eslint`
(0 errors) and `test:types`. Note: the full-suite exit-code failure from 2
pre-existing jsdom `window.scrollTo` unhandled errors reproduces identically on
the untouched base commit — not introduced by this change.

## Numbers (task 5)

`tests/ssr-manifest-dehydrate.perf.test.ts` (gated perf test, excluded from CI;
run with `RUN_BACKPRESSURE_PERF=1 pnpm vitest run tests/ssr-manifest-dehydrate.perf.test.ts`).

Scenario: nested route tree, single request matches **51 routes** (**root** +
50 segments), each segment with 10 preloads + 1 module script + 1 css link;
emitted payload ≈ **75,915 bytes**. Timing covers `serverSsr.dehydrate()` +
`takeBufferedScripts()` only (router.load excluded); n=200 each, median of 3 runs:

| path | median | p95 |
| ---------------------------- | ---------------- | ----------------- |
| uncached (baseline) | 0.55–0.59 ms | ~2.8 ms |
| cached, first request (miss) | 0.53–0.57 ms | ~0.9–3.8 ms |
| cached, subsequent (hit) | **0.24–0.25 ms** | **~0.28–0.49 ms** |

≈ **2.3× faster dehydrate+serialize (~57% less time)** on warm requests, and a
large tail-latency improvement (p95 ~6–10× lower). Cache-miss cost is
indistinguishable from baseline. Absolute savings scale with manifest size;
larger real-world manifests (more assets per route) benefit more.

## Risks / notes

- Prod-only by design: dev-mode HMR mutations of a manifest object in place
would be masked by the cache. If manifests mutate in place in prod
deployments (not a known pattern), stale fragments could be served.
- If user serialization adapters were required to serialize values inside
_static_ manifest routes, the cached fragment is computed with those adapters
on first request; adapters whose output depends on per-request state would be
baked into the first request's bytes (no such adapter exists today; default
plugins handle Error/ReadableStream/RawStream which don't occur in build-time
manifests). A serialization throw falls back to the uncached path.
- Client contract addition: `$_TSR.router.manifest=` may arrive as its own
statement after the router chunk. All framework entry points read the
manifest only via `hydrate()` after script execution, so ordering is safe;
verified against `load-client.ts hydrate()`.
179 changes: 163 additions & 16 deletions packages/router-core/src/ssr/ssr-server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { crossSerializeStream, getCrossReferenceHeader } from 'seroval'
import {
crossSerializeStream,
getCrossReferenceHeader,
serialize,
} from 'seroval'
import { invariant } from '../invariant'
import {
createInlineCssPlaceholderAsset,
Expand All @@ -15,6 +19,7 @@ import { dehydrateSsrMatchId } from './ssr-match-id'
import { defaultSerovalPlugins } from './serializer/seroval-plugins'
import { makeSsrSerovalPlugin } from './serializer/transformer'
import type { LRUCache } from '../lru-cache'
import type { Plugin } from 'seroval'
import type { DehydratedMatch, DehydratedRouter } from './types'
import type { AnySerializationAdapter } from './serializer/transformer'
import type { AnyRouter, ServerSsr } from '../router'
Expand Down Expand Up @@ -164,7 +169,9 @@ class ScriptBuffer {
}
}

const isProd = process.env.NODE_ENV === 'production'
function isManifestSerializationCacheEnabled() {
return process.env.NODE_ENV === 'production'
}

type FilteredRoutes = Manifest['routes']

Expand Down Expand Up @@ -218,6 +225,64 @@ function getInlineCssAssetForPreparedRoutes(
return css === undefined ? undefined : createInlineCssStyleAsset(css)
}

/**
* Pre-serialized static manifest fragment for a matched-route set.
*
* `head`/`tail` wrap the serialized `routes` expression so the fragment can be
* assembled into `$_TSR.router.manifest=<head>routes<tail>`. The routes
* expression is also reused verbatim when request-scoped assets need to
* override the root route (via `Object.assign`).
*/
type SerializedManifestFragment = {
head: string
routes: string
tail: string
}

const SERIALIZED_MANIFEST_CACHE_SIZE = 100
const serializedManifestCaches = new WeakMap<
ServerManifest,
LRUCache<string, SerializedManifestFragment>
>()

function getSerializedManifestCache(
manifest: ServerManifest,
): LRUCache<string, SerializedManifestFragment> {
const cache = serializedManifestCaches.get(manifest)
if (cache) return cache
const newCache = createLRUCache<string, SerializedManifestFragment>(
SERIALIZED_MANIFEST_CACHE_SIZE,
)
serializedManifestCaches.set(manifest, newCache)
return newCache
}

function createSerializedManifestFragment(
manifest: ServerManifest,
preparedRoutes: PreparedMatchedManifestRoutes,
plugins: Array<Plugin<any, any>>,
): SerializedManifestFragment {
let head = '{'
let hasEntries = false
if (manifest.scriptFormat !== undefined) {
head += `"scriptFormat":${JSON.stringify(manifest.scriptFormat)}`
hasEntries = true
}
if (preparedRoutes.inlineCssHrefs) {
if (hasEntries) {
head += ','
}
head += '"inlineStyle":{"attrs":{"suppressHydrationWarning":true}}'
}
head += '"routes":'

return {
head,
routes: serialize(preparedRoutes.routes, { plugins }),
tail: '}',
}
}

function getMatchedRoutesCacheKey(matches: Array<AnyRouteMatch>) {
let cacheKey = ''
for (let i = 0; i < matches.length; i++) {
Expand All @@ -231,7 +296,7 @@ function getPreparedMatchedManifestRoutes(
matches: Array<AnyRouteMatch>,
cacheKey: string,
) {
if (isProd) {
if (isManifestSerializationCacheEnabled()) {
const cached = getManifestCache(manifest).get(cacheKey)
if (cached) {
return cached
Expand All @@ -240,7 +305,7 @@ function getPreparedMatchedManifestRoutes(

const preparedRoutes = prepareMatchedManifestRoutes(manifest, matches)

if (isProd) {
if (isManifestSerializationCacheEnabled()) {
getManifestCache(manifest).set(cacheKey, preparedRoutes)
}

Expand Down Expand Up @@ -504,7 +569,25 @@ export function attachRouterServerSsrUtils({
}
const matches = matchesToDehydrate.map(dehydrateMatch)

const trackPlugins = { didRun: false }
const serializationAdapters = router.options.serializationAdapters as
| Array<AnySerializationAdapter>
| undefined
const plugins = serializationAdapters
? serializationAdapters
.map((t) => makeSsrSerovalPlugin(t, trackPlugins))
.concat(defaultSerovalPlugins)
: defaultSerovalPlugins

// The static portion of the dehydrated router (scriptFormat,
// inlineStyle placeholder, and routes for the matched route set) is
// byte-identical across requests that match the same routes, so its
// serialized form is cached in an LRU and emitted as a separate
// `$_TSR.router.manifest=` assignment right after the initial
// `$_TSR.router=` chunk of the stream. Only dynamic data (matches,
// loader data, request-scoped assets) is streamed per request.
let manifestToDehydrate: Manifest | undefined = undefined
let manifestAssignmentScript: string | undefined = undefined
// Only currently matched routes are dehydrated. Other route assets are
// loaded through dynamic imports when those routes become active.
if (manifest) {
Expand All @@ -525,7 +608,10 @@ export function attachRouterServerSsrUtils({
routes: preparedManifest.routes,
}

// Merge request-scoped assets into root route (without mutating cached manifest)
// Merge request-scoped assets into root route (without mutating cached
// manifest). This only matters for the fallback path where the whole
// manifest is embedded in the streamed graph; the cached-fragment path
// serializes the merged root separately below.
const requestAssets = opts?.requestAssets
if (hasRequestAssets(requestAssets)) {
const existingRoot = manifestToDehydrate.routes[rootRouteId]
Expand All @@ -537,9 +623,72 @@ export function attachRouterServerSsrUtils({
),
}
}

let fragment: SerializedManifestFragment | undefined
if (isManifestSerializationCacheEnabled()) {
const cache = getSerializedManifestCache(manifest)
fragment = cache.get(cacheKey)
if (!fragment) {
try {
fragment = createSerializedManifestFragment(
manifest,
preparedManifest,
plugins,
)
cache.set(cacheKey, fragment)
} catch (err) {
console.error(
'SSR manifest serialization cache fill failed:',
err,
)
fragment = undefined
}
}
}

if (fragment) {
try {
if (!hasRequestAssets(requestAssets)) {
manifestAssignmentScript =
GLOBAL_TSR +
'.router.manifest=' +
fragment.head +
fragment.routes +
fragment.tail
} else if (requestAssets) {
const existingRoot = preparedManifest.routes[rootRouteId]
const mergedRoot = mergeRequestAssetsIntoRootRoute(
existingRoot,
requestAssets,
)
manifestAssignmentScript =
GLOBAL_TSR +
'.router.manifest=' +
fragment.head +
'Object.assign({},' +
fragment.routes +
',{"' +
rootRouteId +
'":' +
serialize(mergedRoot, { plugins }) +
'})' +
fragment.tail
}
} catch (err) {
console.error('SSR manifest assignment serialization failed:', err)
manifestAssignmentScript = undefined
}
}
}

// When the manifest is emitted as a separate cached assignment, it must
// not be part of the streamed graph. Seroval would otherwise assign
// reference IDs within this stream's scope that no other request shares.
const dehydratedRouter: DehydratedRouter = {
manifest: manifestToDehydrate,
manifest:
manifestAssignmentScript !== undefined
? undefined
: manifestToDehydrate,
matches,
}
const dehydratedData = await router.options.dehydrate?.()
Expand All @@ -551,16 +700,6 @@ export function attachRouterServerSsrUtils({
}
_dehydrated = true

const trackPlugins = { didRun: false }
const serializationAdapters = router.options.serializationAdapters as
| Array<AnySerializationAdapter>
| undefined
const plugins = serializationAdapters
? serializationAdapters
.map((t) => makeSsrSerovalPlugin(t, trackPlugins))
.concat(defaultSerovalPlugins)
: defaultSerovalPlugins

let serializationCompleteSignaled = false
const signalSerializationComplete = () => {
if (serializationCompleteSignaled || cleanupStarted) return
Expand Down Expand Up @@ -598,6 +737,14 @@ export function attachRouterServerSsrUtils({
serialized = P_PREFIX + serialized + P_SUFFIX
}
scriptBuffer.enqueue(serialized)
// Emit the cached manifest assignment immediately after the initial
// `$_TSR.router=` chunk so the object exists before we attach the
// manifest, and before any streamed continuation chunks or the end
// signal. ScriptBuffer preserves enqueue order.
if (initial && manifestAssignmentScript !== undefined) {
scriptBuffer.enqueue(manifestAssignmentScript)
manifestAssignmentScript = undefined
}
},
onError: (err: unknown) => {
console.error('Serialization error:', err)
Expand Down
Loading
Loading