From 7932f79cdeac34a2c1c2d651ba90421f62770fe1 Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 10 Apr 2026 23:13:02 -0400 Subject: [PATCH] fix(start): preserve asset object identity when serializing start manifest `JSON.stringify(startManifest)` produces a structurally-cloned tree, which breaks the identity-based dedup used by the downstream SSR serializer. As a result, CSS assets referenced by many routes end up inlined once per route in the SSR HTML payload. Introduce `serializeStartManifestAsModule`, which emits each unique asset as a top-level `const __tsr_aN = {...}` and has every route reference the shared variable by name. When the generated module is loaded, routes that share an asset resolve to the same JS object, so the SSR serializer can dedupe them as intended. On a medium app, the root HTML drops from ~310 KB to ~57 KB. --- .../start-manifest-plugin/manifestBuilder.ts | 38 ++++++ .../src/vite/start-manifest-plugin/plugin.ts | 7 +- .../manifestBuilder.test.ts | 113 ++++++++++++++++++ 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts index c85e0501495..8304a37403e 100644 --- a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts +++ b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts @@ -181,6 +181,44 @@ export function buildStartManifest(options: { } } +/** + * Serializes the manifest as a JS module that declares each unique asset + * once and references it by variable name, preserving object-reference + * sharing so downstream serializers can dedupe shared assets in the SSR HTML. + */ +export function serializeStartManifestAsModule(manifest: { + routes: Record + clientEntry: string +}): string { + const varNameByIdentity = new Map() + const assetDecls: Array = [] + + for (const route of Object.values(manifest.routes)) { + for (const asset of route.assets ?? []) { + const identity = getAssetIdentity(asset) + if (varNameByIdentity.has(identity)) continue + const varName = `__tsr_a${varNameByIdentity.size}` + varNameByIdentity.set(identity, varName) + assetDecls.push(`const ${varName}=${JSON.stringify(asset)};`) + } + } + + // Replace each asset with a sentinel string, then strip the quotes so the + // generated source references the shared const by identifier. + const body = JSON.stringify(manifest, (key, value) => { + if (key === 'assets' && Array.isArray(value)) { + return (value as Array).map( + (asset) => + `@@TSR_ASSET_REF@@${varNameByIdentity.get(getAssetIdentity(asset))!}`, + ) + } + return value + }).replace(/"@@TSR_ASSET_REF@@(__tsr_a\d+)"/g, '$1') + + return `${assetDecls.join('\n')} +export const tsrStartManifest = () => (${body});` +} + export function scanClientChunks( clientBuild: NormalizedClientBuild, ): ScannedClientChunks { diff --git a/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts index 9b443fb073f..7e3725073e8 100644 --- a/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts @@ -2,7 +2,10 @@ import { joinURL } from 'ufo' import { VIRTUAL_MODULES } from '@tanstack/start-server-core' import { resolveViteId } from '../../utils' import { ENTRY_POINTS, START_ENVIRONMENT_NAMES } from '../../constants' -import { buildStartManifest } from '../../start-manifest-plugin/manifestBuilder' +import { + buildStartManifest, + serializeStartManifestAsModule, +} from '../../start-manifest-plugin/manifestBuilder' import type { GetConfigFn, NormalizedClientBuild } from '../../types' import type { PluginOption } from 'vite' @@ -59,7 +62,7 @@ export function startManifestPlugin(opts: { basePath: resolvedStartConfig.basePaths.publicBase, }) - return `export const tsrStartManifest = () => (${JSON.stringify(startManifest)})` + return serializeStartManifestAsModule(startManifest) } return undefined diff --git a/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts b/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts index 66d04e4b03d..1118185a2a7 100644 --- a/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts +++ b/packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts @@ -10,6 +10,7 @@ import { normalizeViteClientBuild, normalizeViteClientChunk, scanClientChunks, + serializeStartManifestAsModule, } from '../../src/start-manifest-plugin/manifestBuilder' import type { Rollup } from 'vite' @@ -1237,3 +1238,115 @@ describe('buildStartManifest route pruning', () => { expect(manifest.routes['/about']).toBeUndefined() }) }) + +// Evaluates the generated module source via `new Function` so each test +// gets its own scope for the `__tsr_aN` consts. +function evaluateManifestModule(source: string) { + const body = `${source.replace( + 'export const tsrStartManifest', + 'const tsrStartManifest', + )}\nreturn tsrStartManifest();` + return new Function(body)() as { + routes: Record + clientEntry: string + } +} + +describe('serializeStartManifestAsModule', () => { + test('preserves object-reference identity across routes that share an asset', () => { + const sharedAsset = { + tag: 'link' as const, + attrs: { + rel: 'stylesheet', + href: '/assets/shared.css', + type: 'text/css', + }, + } + const uniqueToA = { + tag: 'link' as const, + attrs: { + rel: 'stylesheet', + href: '/assets/only-a.css', + type: 'text/css', + }, + } + + const source = serializeStartManifestAsModule({ + routes: { + __root__: { assets: [sharedAsset] }, + '/a': { + filePath: '/routes/a.tsx', + assets: [sharedAsset, uniqueToA], + }, + '/b': { + filePath: '/routes/b.tsx', + assets: [sharedAsset], + }, + }, + clientEntry: '/assets/entry.js', + }) + + const manifest = evaluateManifestModule(source) + + // Shared assets must be the same object across routes so downstream + // TSON serialization can emit back-references instead of re-inlining. + expect(manifest.routes.__root__.assets[0]).toBe( + manifest.routes['/a'].assets[0], + ) + expect(manifest.routes['/a'].assets[0]).toBe( + manifest.routes['/b'].assets[0], + ) + // Non-shared assets remain distinct objects. + expect(manifest.routes['/a'].assets[1]).not.toBe( + manifest.routes['/a'].assets[0], + ) + }) + + test('preserves filePath, children, preloads, and clientEntry fields', () => { + const asset = { + tag: 'link' as const, + attrs: { + rel: 'stylesheet', + href: '/assets/main.css', + type: 'text/css', + }, + } + + const source = serializeStartManifestAsModule({ + routes: { + __root__: { children: ['/a'], assets: [asset] }, + '/a': { + filePath: '/routes/a.tsx', + assets: [asset], + preloads: ['/assets/a.js', '/assets/vendor.js'], + }, + }, + clientEntry: '/assets/entry.js', + }) + + const manifest = evaluateManifestModule(source) + expect(manifest.clientEntry).toBe('/assets/entry.js') + expect(manifest.routes.__root__.children).toEqual(['/a']) + expect(manifest.routes['/a'].filePath).toBe('/routes/a.tsx') + expect(manifest.routes['/a'].preloads).toEqual([ + '/assets/a.js', + '/assets/vendor.js', + ]) + }) + + test('handles manifests where no route carries any assets', () => { + const source = serializeStartManifestAsModule({ + routes: { + __root__: { children: ['/a'] }, + '/a': { filePath: '/routes/a.tsx' }, + }, + clientEntry: '/assets/entry.js', + }) + + const manifest = evaluateManifestModule(source) + expect(manifest.clientEntry).toBe('/assets/entry.js') + expect(manifest.routes.__root__.children).toEqual(['/a']) + expect(manifest.routes['/a'].filePath).toBe('/routes/a.tsx') + expect(manifest.routes['/a'].assets).toBeUndefined() + }) +})