Skip to content

feat(web): add typed preload links to the SSR asset pipeline - #3162

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
v-sayapin:feat/typed-preload-links
Aug 31, 2026
Merged

feat(web): add typed preload links to the SSR asset pipeline#3162
ryansolid merged 2 commits into
solidjs:nextfrom
v-sayapin:feat/typed-preload-links

Conversation

@v-sayapin

Copy link
Copy Markdown

Summary

This reopens the work from dom-expressions#582 in its new home after the DOM runtime was absorbed into the Solid monorepo. It also completes the typed-resource follow-up from #3031, which taught lazy().preload() to register a module's full static JS/CSS closure. The remaining gap was resources whose request cannot be described by a URL alone.

The SSR asset pipeline tracked stylesheets and modulepreloads as bare URLs and inferred the link kind from the .css suffix. That works for JS and CSS, but leaves no way to carry fonts, images, MIME types, CORS mode, SRI, or fetch priority through the same pipeline.

Static manifests and AssetResolver results can now attach explicit PreloadLink[] entries:

preloads: [
  { href: "/assets/inter.woff2", as: "font", type: "font/woff2", crossorigin: "" },
  { href: "/assets/hero.avif", as: "image", type: "image/avif", fetchpriority: "high" }
]

The links are forwarded through the entry manifest, lazy()'s render and preload() paths, its synchronous moduleUrl path for islands, and clientOnly(). Under NoHydration, CSS and non-script preloads are kept while script preloads are skipped unless moduleUrl access explicitly signals that the client will load the module.

as, MIME type, CORS mode, integrity, referrer policy, fetch priority, and media survive renderToString, streaming, embedded-head and custom-sink output, as well as frame transport and late frame delivery. Script and style preloads also receive the matching split CSP nonce.

Links share the existing head resource identity set, so a manifest link and a useHead link dedupe in either registration order. integrity, referrerpolicy, and fetchpriority remain first-registration metadata rather than part of the preload identity; the browser's reuse key is determined by the URL, destination, mode, and credentials mode.

Preloads stay explicit. Raw manifest assets are not preloaded automatically and dynamicImports are not traversed, so the runtime never guesses a request destination or defeats code splitting. The integration that knows the asset type and preload policy selects the links.

The existing stylesheet/modulepreload path stays unchanged: it still uses Set<string>, and the typed-link array is allocated only when the first link is registered. Validation and identity dedupe happen before the document and frame output forms are built.

This also exports the six HTML preload destinations as JSX.HTMLPreloadAs and fetch priority as JSX.HTMLFetchPriority for reuse across the public types.

The second commit fixes late root assets in frames. Root asset chunks share one wire key, so each arrival previously replaced the retained record and a frame mounted later could see only the last one. The warm store now extends the retained record in place and consumes every arrival.

Responsive image preloads (imagesrcset / imagesizes, including the standard form without href) remain a separate follow-up.

How did you test this change?

Added runtime and type coverage for:

  • typed font, image, script, style, fetch, and track preloads
  • MIME type, CORS mode, integrity, referrer policy, fetch priority, media, and nonce routing
  • invalid or missing destinations and empty manifest URLs
  • _base resolution through the static import graph while assets and dynamicImports remain untouched
  • dedupe with useHead in both registration orders and conflicting first-registration metadata
  • pre-shell, late streaming, embedded-head, custom-sink, and frame delivery
  • repeated late root asset chunks in both cold and already-mounted frame stores
  • forwarding through lazy() render, lazy().preload(), moduleUrl, clientOnly(), and NoHydration
  • public server/server-mock type parity

Validation:

  • pnpm exec turbo run build test test-types typecheck --filter=solid-js --filter=@solidjs/web --filter=@solidjs/h --force — 14/14 tasks passed, 0 cached
  • pnpm test — 33/33 workspace tasks passed
  • size limits passed; client scenarios are unchanged and the full frame consumer is 11.19 kB within its 11.22 kB limit
  • Prettier and git diff --check passed

One generator note: pnpm --filter @solidjs/web run jsx-sync also removes a $key block that is present in jsx.d.ts but missing from jsx-h.d.ts. That drift predates this branch and is intentionally not included here.

@changeset-bot

changeset-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d571f8b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Patch
@solidjs/h Patch
solid-js Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal Patch
@solidjs/compiler Patch
@solidjs/signals Patch
@solidjs/diagnostics Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed-hq

codspeed-hq Bot commented Aug 31, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 132 untouched benchmarks
⏩ 132 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
merge 71.9 µs 138.5 µs -48.05%
merge 274.9 µs 186 µs +47.82%
merge 366.9 µs 265.4 µs +38.28%
merge 358.7 µs 265.4 µs +35.17%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing v-sayapin:feat/typed-preload-links (d571f8b) with next (d813a96)

Open in CodSpeed

Footnotes

  1. 132 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@ryansolid ryansolid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed in depth — the substance is right and this completes the #3031 typed-resource thread the way we intended when dom-expressions#582 was parked. The explicit-links-only policy (no destination guessing, no assets auto-preload, no dynamicImports traversal) is exactly the stance we want, the HTMLPreloadAs six-destination split matches the fetch spec's preload destinations, and the first-registration-metadata vs identity split mirrors the browser's preload cache key. The frames identity-consumption + warm-store accumulation interaction also traces correctly for both mounted and late-mount paths.

Three changes needed:

  1. Trim the manifest type to our contract. Drop assets?: string[] and dynamicImports?: string[] from AssetManifest (both server.ts and server-mock.ts). The runtime deliberately never reads them, and the manifest type is our contract, not a Vite-compat surface — we may feed this from non-Vite bundlers, and extra fields on parsed JSON pass through structurally without being in the type. Keep exactly what the pipeline consumes: file, css, imports, isEntry, preloads, _base.

  2. Rebase onto current next. The size-limit failure isn't your measurement being wrong — your branch measured green, but recent landings on next (optimistic-store replay work, among others) share the same budgets, and the merge ref tips four scenarios over by 11–49 bytes.

  3. Re-measure and set the budgets consciously on the rebased branch — including the frames client limit you already bumped (11.22 kB will no longer clear; CI's merge measurement was 11.27 kB).

The local diagnostics.spec.tsx failure you'd see on the current branch is a stale-base artifact (the validate-error compiler change landed with its test fix after your base) — the rebase resolves it.

@ryansolid

Copy link
Copy Markdown
Member

Pushed the requested changes directly (maintainer edits):

web (151) and solid (576) suites pass locally, size-limit exits clean. Thanks for the feature — the substance needed no changes.

@v-sayapin
v-sayapin force-pushed the feat/typed-preload-links branch from 47b1949 to d571f8b Compare August 31, 2026 22:03
@ryansolid
ryansolid merged commit 07471da into solidjs:next Aug 31, 2026
6 checks passed
@v-sayapin

Copy link
Copy Markdown
Author

Sorry — I force-pushed before seeing df453d7 and 47b1949f, so they got overwritten.
Content came out identical, but your comment wording was lost.
I have both restored locally and can send a follow-up, or leave it to you.

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

Labels

None yet

2 participants