Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RouteTreeRoute>
clientEntry: string
}): string {
const varNameByIdentity = new Map<string, string>()
const assetDecls: Array<string> = []

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<RouterManagedTag>).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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -59,7 +62,7 @@ export function startManifestPlugin(opts: {
basePath: resolvedStartConfig.basePaths.publicBase,
})

return `export const tsrStartManifest = () => (${JSON.stringify(startManifest)})`
return serializeStartManifestAsModule(startManifest)
}

return undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
normalizeViteClientBuild,
normalizeViteClientChunk,
scanClientChunks,
serializeStartManifestAsModule,
} from '../../src/start-manifest-plugin/manifestBuilder'
import type { Rollup } from 'vite'

Expand Down Expand Up @@ -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<string, any>
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()
})
})