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() + }) +})