Skip to content

fix(start): preserve asset object identity when serializing manifest#7147

Open
ZacharyL2 wants to merge 1 commit intoTanStack:mainfrom
ZacharyL2:fix/manifest-asset-identity
Open

fix(start): preserve asset object identity when serializing manifest#7147
ZacharyL2 wants to merge 1 commit intoTanStack:mainfrom
ZacharyL2:fix/manifest-asset-identity

Conversation

@ZacharyL2
Copy link
Copy Markdown

@ZacharyL2 ZacharyL2 commented Apr 11, 2026

The problem

The Start manifest's serialization step uses JSON.stringify(startManifest)
in startManifestPlugin. A single CSS asset referenced by many routes is a
shared object in memory at build time — but JSON.stringify produces a
structurally-cloned tree, so every route ends up with its own independent
copy of the asset.

Downstream, the SSR serializer dedupes repeated values using reference
identity
(seenMap.has(obj)). Because the post-JSON.stringify objects
are all distinct, the serializer can't recognize them as repeats and inlines
each copy in full into the SSR HTML.

On a medium-sized app, this caused the same CSS asset object to be inlined
dozens of times into the root document. Observed impact: root HTML went
from ~310 KB → ~57 KB (~81% reduction) after the fix.

This complements #7030, which deduped the manifest at the data layer. The
data structure is already deduped (each shared asset is the same object
in memory), but the serialization step used to discard that sharing before
the downstream serializer saw it.

The fix

Introduce serializeStartManifestAsModule in manifestBuilder.ts. Instead
of JSON.stringify(manifest), the manifest is emitted as a JS module:

const __tsr_a0 = {
  tag: 'link',
  attrs: { rel: 'stylesheet', href: '/assets/foo.css' /* ... */ },
}
const __tsr_a1 = {
  tag: 'link',
  attrs: { rel: 'stylesheet', href: '/assets/bar.css' /* ... */ },
}

export const tsrStartManifest = () => ({
  routes: {
    '/a': { assets: [__tsr_a0, __tsr_a1] },
    '/b': { assets: [__tsr_a0] }, // same __tsr_a0 object
    '/c': { assets: [__tsr_a0] }, // same __tsr_a0 object
    // ...
  },
  clientEntry: '...',
})

Each unique asset is declared once as a module-level const; every route
references it by variable name. When the generated module is loaded, all
routes that share an asset hold the same JS object reference in memory,
so the SSR serializer can collapse them as designed.

Asset uniqueness is determined by the existing getAssetIdentity() helper,
so the identity logic (which already respects tag, href, src, rel,
type, etc.) is reused without change.

Implementation is a single JSON.stringify pass with a replacer that swaps
each assets[] item for a marker string, then a final .replace() over
the produced string converts markers into bare variable references. No
runtime cost — pure build-time codegen.

How I tested it

New unit tests (manifestBuilder.test.ts, 3 tests)

All three are observation-based, using new Function(...) to evaluate the
generated module source and inspect the resulting object graph:

  1. preserves object-reference identity across routes that share an
    asset
    — 3 routes share 1 asset; the eval'd module must satisfy
    routes.a.assets[0] === routes.b.assets[0] === routes.c.assets[0].
  2. preserves filePath, children, preloads, and clientEntry fields
    the JSON replacer only intercepts assets; all other fields must be
    passed through unchanged.
  3. handles manifests where no route carries any assets — empty-assets
    edge case doesn't throw or emit stray declarations.

End-to-end verification in a real app

Measured on a medium TanStack Start app:

metric before after
root HTML size ~310 KB ~57 KB
manifest JS chunk ~310 KB ~88 KB
unique shared asset vars 148
max CSS href repeats in HTML 30+ 2

The 2 remaining "repeats" per CSS href are the legitimate <link> tag in
<head> plus a single declaration in the embedded SSR payload. Each
CSS href now appears exactly where it needs to, once as a <link> and
once as a data field.

Summary by CodeRabbit

Release Notes

  • Improvements

    • Enhanced manifest serialization to deduplicate and reuse asset references across routes, resulting in more efficient module generation and smaller output sizes.
  • Tests

    • Added comprehensive test coverage for manifest serialization, verifying asset identity preservation, route field handling, and optional asset support.

…ifest

`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.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 11, 2026

📝 Walkthrough

Walkthrough

The changes introduce a new serializeStartManifestAsModule function that optimizes start manifest serialization by deduplicating asset objects across routes and emitting them as shared constants, replacing the previous inline JSON stringification approach.

Changes

Cohort / File(s) Summary
Manifest Serialization Enhancement
packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts
Added serializeStartManifestAsModule function that generates a JavaScript module string by scanning routes for assets, computing stable identities, emitting deduplicated asset constants, and post-processing JSON to reference them by identifier rather than inline.
Plugin Integration
packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts
Updated imports and replaced inline JSON.stringify module generation with a call to serializeStartManifestAsModule to produce optimized manifest output.
Test Coverage
packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts
Added test suite for serializeStartManifestAsModule with helper function to evaluate generated module, verifying asset identity preservation across routes, field integrity, and optional asset handling.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A rabbit hops through manifests with glee,
Deduplicating assets wild and free!
No more repeated JSON bloat in sight,
Constants share the burden, shiny-bright.
Module strings dance with newfound grace,
Hopping faster through this codebase space! 🚀

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix(start): preserve asset object identity when serializing manifest' clearly and specifically describes the main change—preserving asset object identity during manifest serialization to fix a deduplication bug. This directly aligns with the changeset's primary objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

Bundle Size Benchmarks

  • Commit: 13ec8a305bbd
  • Measured at: 2026-04-11T03:25:22.686Z
  • Baseline source: history:e61c49ce31af
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 87.51 KiB 0 B (0.00%) 275.69 KiB 76.08 KiB ▁▁▂▃▃▅██▆▆▆
react-router.full 90.77 KiB 0 B (0.00%) 286.88 KiB 78.95 KiB ▁▁▃▆▆▅██▃▃▃
solid-router.minimal 35.60 KiB 0 B (0.00%) 107.36 KiB 31.94 KiB ▁▁▁▅▅▆█████
solid-router.full 40.07 KiB 0 B (0.00%) 120.90 KiB 35.96 KiB ▁▁▁▄▄▆█████
vue-router.minimal 53.46 KiB 0 B (0.00%) 153.16 KiB 48.00 KiB ▁▁▁▂▂▆█████
vue-router.full 58.36 KiB 0 B (0.00%) 168.62 KiB 52.25 KiB ▁▁▁▂▂▆█████
react-start.minimal 101.90 KiB 0 B (0.00%) 323.57 KiB 88.17 KiB ▇▇▇▇▇▆██▇▁▁
react-start.full 105.35 KiB 0 B (0.00%) 333.92 KiB 90.99 KiB ▃▃▄▅▅▆██▇▁▁
solid-start.minimal 49.62 KiB 0 B (0.00%) 153.24 KiB 43.76 KiB ▁▁▁▅▅▆███▂▂
solid-start.full 55.15 KiB 0 B (0.00%) 169.48 KiB 48.46 KiB ▁▁▂▅▅▆███▄▄

Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts`:
- Around line 1249-1252: The helper that evaluates the manifest currently casts
the result to a loose type with "any" (the return of new Function(body)() is
typed as { routes: Record<string, any>; clientEntry: string }); replace that
with a strict manifest type: define or import a Manifest interface (e.g.,
interface Manifest { routes: Record<string, RouteDefinition>; clientEntry:
string }) or use ReturnType from the real manifest builder, then cast the
evaluated result as Manifest (not any) and adjust RouteDefinition to capture the
expected route shape so test assertions are type-checked at compile time; update
the typed symbols "routes" and "clientEntry" accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d8fa3269-e161-4983-9f37-2b3b6597d9e4

📥 Commits

Reviewing files that changed from the base of the PR and between 13ec8a3 and 7932f79.

📒 Files selected for processing (3)
  • packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts
  • packages/start-plugin-core/src/vite/start-manifest-plugin/plugin.ts
  • packages/start-plugin-core/tests/start-manifest-plugin/manifestBuilder.test.ts

@schiller-manuel
Copy link
Copy Markdown
Contributor

please provide a complete reproducer project as a git repo that shows this issue clearly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants