From af2f727b3f8c7cc074c539460ac34372f1d8a064 Mon Sep 17 00:00:00 2001 From: Rames Jusso Date: Wed, 6 May 2026 02:43:58 +0000 Subject: [PATCH] fix(bundler): inline runtime body, drop bare-semi joins, drop empty catch binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues in `bundleToSingleHtml` reported via Abhay's LLM-based code-validity eval against the bundled output. Each is independently small; they share a single PR because they're all artifacts of the bundler-output shape. 1. Empty `src=""` runtime placeholder (real bug) `htmlBundler.ts:injectInterceptor` emitted `` when no `HYPERFRAME_RUNTIME_URL` was configured. Empty `src` resolves to the page URL itself; Chrome flags this as an infinite-fetch hazard. Three other consumers (studioServer, validate, snapshot) post-process the placeholder to substitute either a real URL or an inlined body — `bundleToSingleHtml` did not, so the bundle wasn't actually self-contained despite the function name. Fix: when no URL is configured, inline the runtime IIFE directly via `getHyperframeRuntimeScript()`. Otherwise emit `src=…` as before. 2. Bare-semicolon lines between joined JS chunks (cosmetic) Three sites used `chunks.join("\n;\n")` (body-script coalesce, local JS, composition scripts) which produced a lone `;` on its own line between chunks. Valid JS but a code smell. Replace with a `joinJsChunks()` helper that ensures each chunk ends in `;` and joins on `\n`. 3. Empty `catch (_err) {}` in compositionScoping.ts (lint-noisy) The `_err` underscore prefix signals "intentionally swallowed" but bundle-time linters often don't honor that convention. Replaced with `catch { /* ... */ }` (no binding, explanatory comment) — same behavior, no rule fires. Tests: 2 new regression guards (runtime-not-empty-src, no-bare-semi) plus existing tests updated to reflect the new inlined-runtime shape (the previous "runtime block must not contain getElementById" assertion no longer holds because the inlined body itself uses getElementById; replaced with a more specific "author script not merged into runtime tag" check). Issue #4 from the original report (Unterminated string at line 1111 col 18, char 65497) was not directly reproducible after applying these fixes — esbuild parses all 4 inline scripts in the rebundled output cleanly. The unterminated- string symptom was likely a downstream artifact of the bare-semicolon joining or the empty-src placeholder confusing the lint tool. If the original symptom persists on a clean re-run against the fixed bundle, will open a follow-up PR with a focused repro. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/src/compiler/compositionScoping.ts | 6 +- .../core/src/compiler/htmlBundler.test.ts | 84 ++++++++++++++++++- packages/core/src/compiler/htmlBundler.ts | 44 +++++++--- 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index ea3c55953..5a5030e16 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -212,7 +212,11 @@ export function wrapScopedCompositionScript( value: __hfFindRoot(), configurable: true, }); - } catch (_err) {} + } catch { + // Best-effort: timelines coming from user code may have a frozen target + // or a non-extensible defineProperty path. Swallow — the scoped root + // is an enrichment, not a correctness invariant for playback. + } return timeline; }; var __hfBaseGsap = typeof gsap === "undefined" ? window.gsap : gsap; diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index e6a24ddb0..d9686b849 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -38,10 +38,82 @@ describe("bundleToSingleHtml", () => { )?.[0]; expect(runtimeBlock).toBeDefined(); - expect(runtimeBlock).not.toContain("getElementById"); + // The runtime block must contain the inlined HF runtime IIFE — bundled + // output is self-contained, so the bundle's runtime body is loaded inline, + // not referenced via src. + expect(runtimeBlock).toMatch(/data-hyperframes-preview-runtime="1">/); + expect(runtimeBlock).not.toMatch(/src=""/); + // The author's specific composition script must NOT be merged INTO the + // runtime tag — it stays as its own when no runtime URL was configured. An + // empty src resolves to the page URL itself, which Chrome flags as an + // infinite-fetch hazard. Verify that bundleToSingleHtml inlines the + // runtime body so the bundle is genuinely self-contained. + const dir = makeTempProject({ + "index.html": ` + +
+`, + }); + + const previousUrl = process.env.HYPERFRAME_RUNTIME_URL; + delete process.env.HYPERFRAME_RUNTIME_URL; + let bundled: string; + try { + bundled = await bundleToSingleHtml(dir); + } finally { + if (previousUrl !== undefined) process.env.HYPERFRAME_RUNTIME_URL = previousUrl; + } + + const runtimeBlock = bundled.match( + /]*data-hyperframes-preview-runtime[^>]*>[\s\S]*?<\/script>/i, + )?.[0]; + expect(runtimeBlock).toBeDefined(); + // Must NOT have an empty src attribute (would self-fetch). + expect(runtimeBlock).not.toMatch(/src=""/); + // Must have a non-trivial inlined body (the runtime IIFE is ~150KB). + const innerLength = (runtimeBlock!.match(/>([\s\S]*?)<\/script>/)?.[1] ?? "").length; + expect(innerLength).toBeGreaterThan(1000); + }); + + it("does not produce stray bare-semicolon lines between concatenated JS chunks", async () => { + // Regression guard: hf#XXX. Earlier the bundler joined script chunks with + // `\n;\n`, which produces a lone `;` on its own line between chunks. Valid + // JS but reads as a code smell. Each chunk should end in `;` and chunks + // should join with `\n`. + const dir = makeTempProject({ + "index.html": ` + +
+
+
+ + + +`, + "local-a.js": "window.__a = 1", + "local-b.js": "window.__b = 2", + "compositions/child.html": ``, + }); + + const bundled = await bundleToSingleHtml(dir); + // No line is JUST a bare semicolon (with optional surrounding whitespace). + expect(bundled).not.toMatch(/\n\s*;\s*\n/); + }); + it("hoists external CDN scripts from sub-compositions into the bundle", async () => { const dir = makeTempProject({ "index.html": ` @@ -84,8 +156,14 @@ describe("bundleToSingleHtml", () => { // GSAP CDN from main doc should still be present expect(bundled).toContain("cdn.jsdelivr.net/npm/gsap"); - // data-composition-src should be stripped (composition was inlined) - expect(bundled).not.toContain("data-composition-src"); + // data-composition-src should be stripped from the host element (composition + // was inlined). The literal string may still appear inside the inlined + // runtime IIFE that knows how to look up that attribute — so check the DOM, + // not the raw text. + const { document: doc } = parseHTML(bundled); + const hostEl = doc.getElementById("rockets-host"); + expect(hostEl).toBeTruthy(); + expect(hostEl?.hasAttribute("data-composition-src")).toBe(false); }); it("does not duplicate CDN scripts already present in the main document", async () => { diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 3a80d58c9..00cae2a0e 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -10,6 +10,7 @@ import { import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths"; import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping"; import { validateHyperframeHtmlContract } from "./staticGuard"; +import { getHyperframeRuntimeScript } from "../generated/runtime-inline"; /** Resolve a relative path within projectDir, rejecting traversal outside it. */ function safePath(projectDir: string, relativePath: string): string | null { @@ -30,8 +31,20 @@ function injectInterceptor(html: string): string { const sanitized = stripEmbeddedRuntimeScripts(html); if (sanitized.includes(RUNTIME_BOOTSTRAP_ATTR)) return sanitized; - const runtimeScriptUrl = getRuntimeScriptUrl().replace(/"/g, """); - const tag = ``; + // When a runtime URL is configured (HYPERFRAME_RUNTIME_URL env var), the bundle + // points at it via src=… and the host page serves the script. When no URL is + // configured — the common `bundleToSingleHtml` use case — inline the runtime + // body so the bundle is genuinely self-contained. An empty src="" attribute + // would otherwise resolve to the page URL and trigger an infinite-fetch loop. + const runtimeScriptUrl = getRuntimeScriptUrl(); + let tag: string; + if (runtimeScriptUrl) { + const escaped = runtimeScriptUrl.replace(/"/g, """); + tag = ``; + } else { + const inlinedRuntime = getHyperframeRuntimeScript(); + tag = ``; + } if (sanitized.includes("")) { return sanitized.replace("", `${tag}\n`); } @@ -268,11 +281,7 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void { return !type || type === "text/javascript" || type === "application/javascript"; }); if (bodyInlineScripts.length > 0) { - const mergedJs = bodyInlineScripts - .map((el) => (el.textContent || "").trim()) - .filter(Boolean) - .join("\n;\n") - .trim(); + const mergedJs = joinJsChunks(bodyInlineScripts.map((el) => el.textContent || "")); for (const el of bodyInlineScripts) el.remove(); if (mergedJs) { const stripped = stripJsCommentsParserSafe(mergedJs); @@ -283,6 +292,20 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void { } } +/** + * Concatenate JS chunks safely. Each chunk gets a trailing `;` if it doesn't + * already end in one, so the joined output never inserts a stray bare-semicolon + * line between chunks (the `\n;\n` separator pattern produces a lone `;` on its + * own line, which is valid JS but reads as a code smell to most linters). + */ +function joinJsChunks(chunks: string[]): string { + return chunks + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.length > 0) + .map((chunk) => (chunk.endsWith(";") ? chunk : chunk + ";")) + .join("\n"); +} + function stripJsCommentsParserSafe(source: string): string { if (!source) return source; try { @@ -379,12 +402,13 @@ export async function bundleToSingleHtml( } if (localJsChunks.length > 0) { const anchor = document.querySelector('script[data-hf-bundled-local-js="1"]'); + const joinedJs = joinJsChunks(localJsChunks); if (anchor) { anchor.removeAttribute("data-hf-bundled-local-js"); - anchor.textContent = localJsChunks.join("\n;\n"); + anchor.textContent = joinedJs; } else { const script = document.createElement("script"); - script.textContent = localJsChunks.join("\n;\n"); + script.textContent = joinedJs; document.body.appendChild(script); } } @@ -623,7 +647,7 @@ export async function bundleToSingleHtml( } if (compScriptChunks.length) { const compScript = document.createElement("script"); - compScript.textContent = compScriptChunks.join("\n;\n"); + compScript.textContent = joinJsChunks(compScriptChunks); document.body.appendChild(compScript); }