fix: proper script importmap & speculationrules handling#734
Conversation
Align type, normalization, escaping, capo sort and dedupe for `<script type="importmap">` and `<script type="speculationrules">` so both work cleanly without casts or manual `tagPriority` hints. importmap: - ImportMapScript now uses DataScriptTextContent so both `innerHTML` and `textContent` are accepted, with string or ImportMapConfig - normalize.ts auto-JSON.stringify objects passed for importmap - resolve.ts applies the `\u003C` escape for importmap content - sort.ts pins importmap at sync weight (50) so it always precedes modulepreload (70) and module scripts (80) per HTML spec - dedupe.ts treats importmap as unique per document (HTML spec) speculationrules: - resolve.ts applies the `\u003C` escape alongside JSON types - sort.ts pins speculationrules at weight 90, alongside `<link rel=prefetch>` / `rel=prerender`, instead of sync (50) Docs: updated capo weight table with the full set of weights (including the incorrect CSP value and the new importmap / speculationrules entries).
📝 WalkthroughWalkthroughUpdated head tag ordering and sanitization: explicit weights introduced (CSP, viewport, importmap, speculationrules), importmap script typing now accepts either Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Bundle Size Analysis
|
importmap & speculationrules handling
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
packages/unhead/test/unit/plugins/capo.test.ts (1)
161-228: Exercise the new object/textContent path in these cases.These ordering tests still pass pre-serialized
innerHTML, so they won’t catch regressions in the new normalization/typing path this PR adds. Swapping one importmap/speculationrules fixture to raw objecttextContent(or raw objectinnerHTML) would cover the user-facing API more directly.🧪 Suggested coverage tweak
head.push({ script: [{ type: 'importmap', - innerHTML: JSON.stringify({ imports: { '#entry': '/entry.js' } }), + textContent: { imports: { '#entry': '/entry.js' } }, }], }) @@ head.push({ script: [{ type: 'speculationrules', - innerHTML: JSON.stringify({ prefetch: [{ source: 'list', urls: ['/next'] }] }), + textContent: { prefetch: [{ source: 'list', urls: ['/next'] }] }, }], })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/test/unit/plugins/capo.test.ts` around lines 161 - 228, The tests 'importmap precedes modulepreload and module scripts' and 'speculationrules sorts late alongside prefetch/prerender' currently pass pre-serialized JSON strings via innerHTML; update each push that creates the importmap/speculationrules script to use the raw object path by replacing innerHTML: JSON.stringify(...) with textContent: { ... } (i.e. pass the object literal for imports/prefetch) so the new object/textContent normalization path is exercised (look for the script pushes in those two it blocks and update the innerHTML fields to textContent objects).
🤖 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/unhead/src/types/schema/script.ts`:
- Around line 297-305: The ImportMapScript type currently uses
DataScriptTextContent<string | ImportMapConfig> which leaves both textContent
and innerHTML optional; update ImportMapScript to use the same XOR pattern as
InlineScript (lines around InlineScript) so one union arm requires textContent
and the other requires innerHTML (e.g., DataScriptTextContent<string |
ImportMapConfig> replaced by a union like { textContent: string |
ImportMapConfig; innerHTML?: never } | { innerHTML: string | ImportMapConfig;
textContent?: never }) to ensure an importmap must include one of those
properties.
In `@packages/unhead/src/utils/dedupe.ts`:
- Around line 21-23: Remove the importmap special-case from dedupeKey so
importmap scripts are not forced to a single 'script:importmap' key: delete the
conditional that checks for t === 'script' && props.type === 'importmap' inside
dedupeKey and let importmap nodes be deduplicated normally by their full key;
then update the test packages/unhead/test/unit/server/deduping.test.ts (remove
or change assertions that expect "last one wins" for importmap) and update docs
in docs/head/1.guides/1.core-concepts/2.positions.md to reflect that multiple
importmap scripts are merged by browsers rather than replaced.
---
Nitpick comments:
In `@packages/unhead/test/unit/plugins/capo.test.ts`:
- Around line 161-228: The tests 'importmap precedes modulepreload and module
scripts' and 'speculationrules sorts late alongside prefetch/prerender'
currently pass pre-serialized JSON strings via innerHTML; update each push that
creates the importmap/speculationrules script to use the raw object path by
replacing innerHTML: JSON.stringify(...) with textContent: { ... } (i.e. pass
the object literal for imports/prefetch) so the new object/textContent
normalization path is exercised (look for the script pushes in those two it
blocks and update the innerHTML fields to textContent objects).
🪄 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: 61577b69-6468-4c59-81b3-2f78b63d8ed1
📒 Files selected for processing (8)
docs/head/1.guides/1.core-concepts/2.positions.mdpackages/unhead/src/server/sort.tspackages/unhead/src/types/schema/script.tspackages/unhead/src/utils/dedupe.tspackages/unhead/src/utils/normalize.tspackages/unhead/src/utils/resolve.tspackages/unhead/test/unit/plugins/capo.test.tspackages/unhead/test/unit/server/deduping.test.ts
Address CodeRabbit review on the importmap/speculationrules PR.
- Multiple importmaps are allowed per the modern HTML spec — browsers
merge them into a single global import map. Remove the `script:importmap`
auto-unique dedupe key so both (or more) tags render and the browser
merges them. Content-based fallback still collapses exact duplicates,
and users who want replace-semantics can opt in via an explicit `key`.
- Move importmap to a dedicated capo weight (25) between preconnect (20)
and async scripts (30). This guarantees importmap precedes async
module scripts (`<script type="module" async>`) as required by the
HTML spec, without touching the module/async classification.
- Tighten `ImportMapScript` type with an XOR between `textContent` and
`innerHTML` (matching `InlineScript`) so one is always required.
- Rewrite the importmap dedupe tests to assert merging for distinct
pushes, content-based collapse for identical pushes, and key-based
replacement for opt-in last-wins.
- Swap capo ordering tests to push `textContent: { ... }` raw objects,
exercising the normalize auto-serialization path end-to-end.
- Doc update: new weight 25 for importmap, note that unhead emits all
importmaps so browsers can merge them.
There was a problem hiding this comment.
Pull request overview
This PR improves first-class handling of <script type="importmap"> and <script type="speculationrules"> across Unhead’s typing, normalization, SSR escaping, capo sorting, deduping behavior, documentation, and unit tests—so callers can provide { type, textContent/innerHTML } without casts or manual tagPriority.
Changes:
- Update script typing + normalization so
importmap/speculationrulesaccept object content and are auto-serialized to JSON. - Adjust SSR sanitize/escaping behavior and capo weights so importmaps sort before module-related resources and speculationrules sort late.
- Expand docs + add unit tests for ordering and deduping expectations.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/unhead/src/types/schema/script.ts | Refines ImportMapScript typing to allow either textContent or innerHTML (string or config object). |
| packages/unhead/src/utils/normalize.ts | Auto-serializes object textContent/innerHTML for importmap in addition to JSON-like script types. |
| packages/unhead/src/utils/resolve.ts | Extends JSON-like escaping behavior to importmap and speculationrules (currently innerHTML-only). |
| packages/unhead/src/server/sort.ts | Adds capo weights and ordering logic for importmap and speculationrules. |
| packages/unhead/test/unit/plugins/capo.test.ts | Adds assertions for importmap ordering relative to modulepreload/modules and speculationrules late sorting. |
| packages/unhead/test/unit/server/deduping.test.ts | Adds dedupe coverage for multiple importmaps, identical content dedupe, and key-based last-wins. |
| docs/head/1.guides/1.core-concepts/2.positions.md | Replaces partial capo weight list with a fuller table and adds importmap/speculationrules guidance. |
Comments suppressed due to low confidence (2)
packages/unhead/src/utils/resolve.ts:90
- sanitizeTags only applies the
\u003Cescaping logic wheninnerHTMLis present. Fortype="importmap"/type="speculationrules"(and existing JSON types), the recommended path is oftentextContent, which currently bypasses this normalization and can lead to inconsistent SSR output depending on whether callers usetextContentvsinnerHTML. Consider applying the same normalization/escaping totextContentfor these JSON-like script types (or normalizing to a single content field before rendering).
if (tag === 'script' && innerHTML) {
const type = String(props.type)
t.innerHTML = type.endsWith('json') || type === 'importmap' || type === 'speculationrules'
? (typeof innerHTML === 'string' ? innerHTML : JSON.stringify(innerHTML)).replace(LT_RE, '\\u003C')
: typeof innerHTML === 'string' ? innerHTML.replace(SCRIPT_END_RE, '<\\/script') : innerHTML
t._d = dedupeKey(t)
}
packages/unhead/src/server/sort.ts:46
- capoTagWeight classifies inline sync scripts using
tag.innerHTML, but inline scripts can also be provided viatextContent(per the schema types). As written,<script textContent="...">will fall through to the default weight (100) instead of the sync-script bucket, making ordering depend on whether callers choseinnerHTMLvstextContent. Update the sync-script condition to treattextContentthe same asinnerHTMLfor inline scripts.
else if (isTruthy(tag.props.async))
weight = CAPO_WEIGHTS.script.async
else if ((tag.props.src && !isTruthy(tag.props.defer) && !isTruthy(tag.props.async) && type !== 'module' && !type.endsWith('json')) || (tag.innerHTML && !type.endsWith('json')))
weight = CAPO_WEIGHTS.script.sync
else if ((isTruthy(tag.props.defer) && tag.props.src && !isTruthy(tag.props.async)) || type === 'module')
weight = CAPO_WEIGHTS.script.defer
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const CAPO_WEIGHTS = { | ||
| meta: { 'content-security-policy': -30, 'charset': -20, 'viewport': -15 }, | ||
| link: { 'preconnect': 20, 'stylesheet': 60, 'preload': 70, 'modulepreload': 70, 'prefetch': 90, 'dns-prefetch': 90, 'prerender': 90 }, | ||
| script: { async: 30, defer: 80, sync: 50 }, | ||
| script: { importmap: 25, async: 30, defer: 80, sync: 50, speculationrules: 90 }, | ||
| style: { imported: 40, sync: 60 }, |
There was a problem hiding this comment.
PR description mentions pinning importmap to the sync-script weight (50), but CAPO_WEIGHTS sets importmap: 25 (and logic depends on it to sort before async module scripts). Please align the PR description/release notes with the implemented weights to avoid confusion for reviewers and future maintainers.
Address Copilot review on #734. - `sanitizeTags` (resolve.ts) now escapes both `textContent` and `innerHTML` for scripts. Previously only `innerHTML` received the `\u003C` escape for JSON-like types (importmap, speculationrules, json), which meant callers using the recommended `textContent` path got inconsistent SSR output compared to `innerHTML`. - `capoTagWeight` (sort.ts) now classifies inline sync scripts by `textContent` as well as `innerHTML`. Previously a plain inline script using `textContent` fell through to the default weight 100 instead of the sync-script bucket (50). - Added a capo test for inline `textContent` sort classification. - Added xss tests covering the importmap/speculationrules textContent escape path. - Updated the existing useHeadSafe ld+json test to codify the new consistent behavior: `<` characters in JSON values are escaped to `\u003C` regardless of textContent vs innerHTML, as defense-in-depth.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
packages/unhead/src/utils/resolve.ts (1)
85-86: Normalize scripttypebefore JSON-like matching.Line 85 uses raw
props.type, so mixed-case values (e.g.Application/LD+JSON,ImportMap) won’t take the JSON-like escape path. Normalize once before checks to keep behavior consistent.Suggested patch
- const type = String(props.type) + const type = String(props.type || '').trim().toLowerCase() const isJsonLike = type.endsWith('json') || type === 'importmap' || type === 'speculationrules'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/src/utils/resolve.ts` around lines 85 - 86, The JSON-like detection uses the raw props.type so mixed-case values slip through; normalize the type string to a consistent case (e.g. lower-case) before performing checks. In resolve.ts update the handling around the type constant and isJsonLike (the String(props.type) assignment and the isJsonLike check) to use a normalized value (e.g. assign const type = String(props.type).toLowerCase() or create a separate normalizedType variable) and then match against 'json', 'importmap', and 'speculationrules' on that normalized value.packages/unhead/test/unit/server/xss.test.ts (1)
354-380: Optional: assert scripttypein both new XSS tests for clearer diagnostics.These tests are good; adding
type="importmap"/type="speculationrules"assertions would make regressions easier to pinpoint if rendering behavior changes.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/unhead/test/unit/server/xss.test.ts` around lines 354 - 380, Add explicit assertions that the rendered script tag includes the correct type attribute in each new XSS test to improve diagnostics: in the "importmap textContent is escaped like JSON types" test, assert that ctx.headTags contains type="importmap"; and in the "speculationrules textContent is escaped like JSON types" test, assert that ctx.headTags contains type="speculationrules". Locate the checks after calling renderSSRHead(head) (functions createServerHeadWithContext and renderSSRHead) and add the type assertions alongside the existing checks for '\\u003C' and absence of the raw closing-script pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/unhead/src/utils/resolve.ts`:
- Around line 85-86: The JSON-like detection uses the raw props.type so
mixed-case values slip through; normalize the type string to a consistent case
(e.g. lower-case) before performing checks. In resolve.ts update the handling
around the type constant and isJsonLike (the String(props.type) assignment and
the isJsonLike check) to use a normalized value (e.g. assign const type =
String(props.type).toLowerCase() or create a separate normalizedType variable)
and then match against 'json', 'importmap', and 'speculationrules' on that
normalized value.
In `@packages/unhead/test/unit/server/xss.test.ts`:
- Around line 354-380: Add explicit assertions that the rendered script tag
includes the correct type attribute in each new XSS test to improve diagnostics:
in the "importmap textContent is escaped like JSON types" test, assert that
ctx.headTags contains type="importmap"; and in the "speculationrules textContent
is escaped like JSON types" test, assert that ctx.headTags contains
type="speculationrules". Locate the checks after calling renderSSRHead(head)
(functions createServerHeadWithContext and renderSSRHead) and add the type
assertions alongside the existing checks for '\\u003C' and absence of the raw
closing-script pattern.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1fc846e6-4d4b-45be-b6d0-af87a5fd0380
📒 Files selected for processing (5)
packages/unhead/src/server/sort.tspackages/unhead/src/utils/resolve.tspackages/unhead/test/unit/plugins/capo.test.tspackages/unhead/test/unit/server/useHeadSafe-edge-cases.test.tspackages/unhead/test/unit/server/xss.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/unhead/src/server/sort.ts
- packages/unhead/test/unit/plugins/capo.test.ts
🔗 Linked issue
N/A
❓ Type of change
📚 Description
<script type="importmap">and<script type="speculationrules">mostly worked by accident and required casts liketype: 'importmap' as unknown as 'application/json'alongside manualtagPriorityhints. This PR aligns typing, normalization, escaping, capo sort and dedupe so both tags work cleanly with just{ type, innerHTML }or{ type, textContent }.importmap
ImportMapScriptnow usesDataScriptTextContent<string | ImportMapConfig>, so bothinnerHTMLandtextContentare accepted (string or config object) — no more casts.normalize.tsauto-JSON.stringifys objects passed forimportmap.resolve.tsapplies the\u003Cescape to importmap content, matching JSON-type scripts.sort.tspins importmap at the sync-script weight (50) so it is always emitted beforemodulepreload(70) andmodulescripts (80) as required by the HTML spec.dedupe.tstreats importmap as unique per document (HTML spec allows only one).speculationrules
resolve.tsapplies the\u003Cescape to speculationrules content alongside JSON types.sort.tspins speculationrules at weight 90, alongside<link rel=prefetch>/rel=prerender, instead of accidentally landing in the sync-script bucket (50).Docs
docs/head/1.guides/1.core-concepts/2.positions.md— replaced the incomplete capo weight list with the full table. Fixes the incorrect CSP entry (0→-30) and adds viewport, async/sync/defer/module scripts, importmap, speculationrules, stylesheets, preloads and the prefetch tier.Tests
test/unit/plugins/capo.test.ts— importmap-before-modulepreload-and-modules ordering; speculationrules-sorted-late alongside prefetch.test/unit/server/deduping.test.ts— two importmap pushes collapse to the last one.Summary by CodeRabbit
New Features
Documentation
Tests