Skip to content

fix(studio): handle full HTML doc sub-compositions in preview#885

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/map-preview-blocks
May 16, 2026
Merged

fix(studio): handle full HTML doc sub-compositions in preview#885
miguel-heygen merged 2 commits into
mainfrom
fix/map-preview-blocks

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented May 16, 2026

Summary

  • Root cause: buildSubCompositionHtml assumed all sub-compositions used <template> wrappers. Full HTML document blocks (like north-korea-locked-down and nyc-paris-flight) were nested as-is inside <body>, producing invalid HTML with nested <html> and <head> elements
  • Effect: the composition's <style> tags ended up misplaced inside <body>, and <img src="assets/..."> paths failed to resolve when combined with the injected <base> tag — resulting in missing map images in the Studio sub-composition preview
  • Fix: detect full HTML documents and properly extract head styles/scripts and body content into separate sections, producing valid HTML where CSS lands in <head> and relative asset paths resolve correctly

Test plan

  • New unit test: full HTML document composition produces clean output without nested <html> in <body>
  • Existing test: <template>-wrapped compositions still rewrite ../ asset paths correctly
  • Visual verification: captured sub-composition preview frames before/after fix — maps now render correctly for both blocks
  • Manual: open a project with north-korea-locked-down or nyc-paris-flight as a sub-composition in Studio, click on the sub-comp in the timeline → map should be visible

buildSubCompositionHtml previously assumed all sub-compositions used
<template> wrappers. Full HTML document compositions (like the
north-korea-locked-down and nyc-paris-flight registry blocks) were
nested as-is inside <body>, producing invalid HTML with nested <html>
and <head> elements. This caused <style> and <script> tags from the
composition's <head> to be misplaced, and asset paths to fail when
combined with the injected <base> tag.

Now detects full HTML documents and extracts head styles/scripts and
body content separately, producing clean valid HTML where the
composition's CSS lands in <head> and asset-relative paths resolve
correctly via the <base> tag.
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

LGTM — clean three-way dispatch addressing a real-bug class.

Audited

Root cause is precise: the old templateMatch?.[1] ?? rawComp fallback silently took the rawComp branch for any composition that didn't have a <template> wrapper. For full HTML docs, that meant the entire <!doctype html><html>...</html> got dumped inside <body>${content}</body>, producing invalid nested HTML. The composition's <head> styles + scripts (GSAP CDN, custom CSS) were left inside that nested-<head>-inside-<body> mess where browsers ignore them. ✓

Fix shape — three-way dispatch:

  1. Template-wrapped → extract template content + rewrite (existing behavior, refactored into the same DOM-rewrite block)
  2. Full HTML document → new extractFullDocumentParts extracts head styles/scripts and body content SEPARATELY via linkedom, rewrites assets across both doc.head and doc.body, returns { headStyles, headScripts, bodyContent }
  3. Raw fragment (no template, no full doc) → parse as <body>${rawComp}</body> and rewrite (new explicit branch)

The dispatch order (template-first → full-doc → fragment) is correct: a template wrapper takes precedence over its inner content shape, and only no-template files fall through to the doc-detection branch. ✓

isFullHtmlDocument predicate:

return /<!doctype\s|<html[\s>]/i.test(html);

Catches <!DOCTYPE html> / <!doctype HTML> and <html> / <html lang="en">. Case-insensitive. Minor: pattern matches anywhere in the string, so a comp file with a commented-out <!-- <html> reference --> would false-positive into the full-doc branch. Practically benign — linkedom would still parse the comment-only HTML correctly and doc.body.innerHTML would return the actual content — but worth a one-line ^\s* anchor if you want to be strict.

extractFullDocumentParts:

  • Iterates both doc.head AND doc.body for asset rewrites ✓ (head can have <link href="..."> references too)
  • headStyles and headScripts extracted from <head> only — composition's body scripts stay in the body content where they belong ✓
  • Returns clean separated parts that the caller assembles into the final standalone page

Composition's head deps appended to assembled head:

if (compHeadStyles) headContent += `\n${compHeadStyles}`;
if (compHeadScripts) headContent += `\n${compHeadScripts}`;

Both appended AFTER the index.html head content, so the composition's deps load last — if there's a conflict with index.html (e.g., both load different GSAP versions), the composition wins. Reasonable default for the "sub-comp declared its own deps" case the body cites.

Test coverage

New test pins the full-doc branch with 6 assertions:

  • No <html> or <head> AFTER <body> (verifies the nesting bug is fixed)
  • Composition styles preserved (.map, #root)
  • Image src preserved (no over-rewriting bare relative paths)
  • <base href="..."> for asset resolution
  • GSAP CDN script preserved
  • Body script __timelines["map-block"] preserved

Existing tests still cover the template-wrapped branch (+52 -0 is net-additive). ✓

Body claim verification

  • "buildSubCompositionHtml assumed all sub-compositions used <template> wrappers" — verified, the old code's templateMatch?.[1] ?? rawComp is exactly that assumption ✓
  • "Full HTML document blocks were nested as-is inside <body>, producing invalid HTML with nested <html> and <head> elements" — verified by reading the old code path ✓
  • "north-korea-locked-down and nyc-paris-flight" — concrete repro names; test fixture mirrors that shape via a map-block.html

Non-blocking notes

  1. Duplicate rewrite block in template-wrapped + raw-fragment branches (subComposition.ts:79-107 and 120-150) — same parseHTML + rewriteAssetPaths + rewriteInlineStyleAssetUrls + rewriteCssAssetUrls sequence in both. Could DRY into a helper rewriteFragmentContent(content, compPath) → string. Cosmetic.

  2. isFullHtmlDocument regex matches anywhere^\s* anchor would be strict-mode for the unlikely commented-out-<html> false-positive. Practical impact: nil.

  3. No test for the raw-fragment branch — the third path (no template, no full doc) is exercised by some pre-existing call site presumably, but isn't pinned by this commit's test additions. A short test for compositions/fragment.html containing <div data-composition-id="x">...</div> (no doctype, no template) would round out the matrix.

  4. Comp's <script> last-write-wins — appending after index.html's head means a comp that loads GSAP 3.14 will override an index.html GSAP 3.12. Body framing makes this sound intentional; worth a sentence in the docstring of extractFullDocumentParts so future readers know the precedence is deliberate.

CI

All required green or in-progress on cc9c7eb0. The earlier run shows Build, Test: runtime contract, Lint, Format, Preflight, Tests on windows-latest, Render on windows-latest, player-perf, Perf:fps all green; current run has Test, Typecheck, CLI smoke (required), Smoke: global install in-progress + regression-shards in-progress. No failures.

Review by Rames Jusso (pr-review)

Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

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

Summary. Targeted fix for full-HTML-document sub-compositions in the Studio preview: detect doctype/<html>, parse via linkedom, extract head <style>/<script> + body content separately, append to the project's head. Avoids the nested <html>/<head>-in-<body> bug. Scope is small (175/-26 across one .ts + one .test.ts), checks are green except two Windows jobs still in flight at the head sha.

Calibrated strengths.

  • subComposition.ts:10-15 — extraction into isFullHtmlDocument + extractFullDocumentParts keeps the new branch isolated and readable, and the rewrite pass is correctly applied to both doc.head and doc.body (subComposition.ts:25-43), so head-scoped <style> url(..) references get rewritten too — not just body assets.
  • Test pins the right negative invariant: expect(afterBody).not.toContain("<html") and not.toContain("<head>") (subComposition.test.ts:55-58), which is the actual regression class — not just "the maps render."
  • The three branches (<template> / full-doc / raw fragment) are mutually exclusive and ordered correctly: <template> first (so a future composition with a <template> wrapper containing a <html> reference inside doesn't get misclassified as full-doc), then full-doc, then raw-fragment fallback.

Findings.

important — composition <head> <link> / <meta> are silently dropped.
extractFullDocumentParts extracts only <style> and <script> from doc.head (subComposition.ts:38-44). Any <link rel="stylesheet" href="...">, <link rel="preload" ...>, or <meta> in the sub-comp's head gets lost when the doc is reassembled. The two blocks named in the PR body use inline <style> only, so the fix works for them — but the next composition someone adds with an external stylesheet will render unstyled with no error. Either widen the extraction to include link + meta (cheap — same outerHTML + join pattern), or add an explicit "unsupported head element" warning so the failure mode is loud, not silent. At minimum, document the assumption in the function jsdoc so the next author knows the contract.

nit — three near-identical rewrite blocks.
subComposition.ts:88-105, subComposition.ts:117-136, and the head/body loop inside extractFullDocumentParts (subComposition.ts:25-43) all do the same rewriteAssetPaths + rewriteInlineStyleAssetUrls + rewriteCssAssetUrls triad over a linkedom doc fragment. Extracting a single rewritePathsOnContainer(container, compPath) helper would let the three branches each be a 1-liner and would prevent future drift between them. Not blocking — file is small — but the duplication will accumulate the next time someone touches this.

nit — <html> / <body> attributes lost.
doc.body.innerHTML discards anything on the <html> element (e.g. lang="en") and the <body> element itself (class, data-*, inline style). The two known blocks don't use these, but worth a comment in the function so the constraint is explicit.

nit — bare-relative paths in nested compositions.
Pre-existing behavior, not introduced here, but the PR body claims "relative asset paths resolve correctly" — which is true only because <base> points at the project root AND the two blocks live at compositions/<name>.html (one level deep) with assets at project-root assets/. A composition at compositions/sub/foo.html referencing assets/map.png (meaning relative to its own dir) would still 404, since rewriteAssetPaths only rewrites ../ paths. Worth a follow-up if more nesting comes online.

Notes (non-blocking).

  • Verified the only consumer is studio-api/routes/preview.ts:215 (preview-time, trusted-content path) — so hoisting composition <script> tags into the main page's <head> doesn't widen any attack surface.
  • CI: mergeStateStatus=BLOCKED is REVIEW_REQUIRED (reviewer-gate), not a CI-gate; required checks all SUCCESS at the head sha except Windows render/tests still IN_PROGRESS at review time.

Verdict: APPROVE
Reasoning: The fix is correct and tested for the two compositions named in the body, and the change is well-isolated. The dropped <link>/<meta> is the one substantive gap, but it's a "next composition" concern with a loud failure mode (missing styles) rather than a silent correctness regression — fine as a follow-up.

Review by Vai

…ward attrs

Addresses review notes from hf#885:

- Extract full <head> innerHTML instead of only <style>+<script>. This
  preserves <link rel="stylesheet">, <meta>, and any other head-level
  tags. Prevents silent-degrade when a composition ships external CSS.

- Extract and forward <html> and <body> attributes (lang, class, data-*)
  to the assembled output page.

- DRY the triplicated rewrite block into rewriteRelativePaths() shared
  across template, full-doc, and fragment branches.

- Anchor isFullHtmlDocument regex with ^\s* to avoid false positives
  from stray <html> inside script/template content.

- Add fragment-branch test (raw HTML without template or doctype).

- Document the deliberate last-write-wins precedence for composition
  scripts appended after the project head.
Copy link
Copy Markdown
Collaborator Author

Merge activity

  • May 16, 4:21 AM UTC: Graphite couldn't merge this PR because it failed for an unknown reason (GitHub is reporting that this PR is not mergeable, despite passing required status checks defined by your branch protection rules. Please check your rulesets for additional blocking criteria. Graphite Merge Queue does not currently support rulesets. Please contact Graphite support for further assistance.).

@miguel-heygen miguel-heygen merged commit 3336fdd into main May 16, 2026
44 checks passed
@miguel-heygen miguel-heygen deleted the fix/map-preview-blocks branch May 16, 2026 04:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants