fix: preview and bundled scripts run where and when the author put them - #5014
Conversation
Edit accuracy: accurate 1562 (base branch 1562), smooth 1305 of thoseThe gate passes. Quarantined, measured but not gated (0) |
The bundler joined every local <script src> into one inline script at the first one's position. That dropped defer and async (a deferred main.js ran before a deferred CDN gsap), moved later files ahead of CDN scripts between them, and let one file's error stop the rest. Each local script is now inlined where it stands with its attributes; a deferred or async one keeps its tag with its file as a data: URL. Inline-script merging also no longer folds in nomodule scripts, which dropped the attribute.
…d inlined files are escaped - When the bundler returns nothing or throws, the preview places the runtime where the bundled page does (end of head) instead of after the composition's scripts or not at all; core's placement moves to insertRuntimeTag. - Every HTML-spec JavaScript MIME type counts as a classic script for merging and the web-font gate. - Inlined local files and merged runs are escaped with escapeInlineScriptSource.
bc80685 to
77c38f3
Compare
… runtime reads survive - Local defer/async scripts are inlined in their own tag with the attribute kept; the font-gate runner (and its old-runtime fallback) runs an inline classic defer script after the classic scripts, in document order, so a page policy that allows inline scripts but not data: still runs them. - stripEmbeddedRuntimeScripts matches the bootstrap attribute and runtime file names on the tag, not runtime words in an authored script's text. - The preview applies its runtime on every path, so an adapter page without one still gets it; the inline escaper writes <!-- as \x3C!--, valid in a unicode regex.
…ops the runtime being added
…nders skip a CDN runtime link
96a12f0 to
46369b1
Compare
jrusso1020
left a comment
There was a problem hiding this comment.
Approving at b8516ff9.
Correctness, checked against the code:
- Bundler: each local file now becomes its own inline tag, marked
data-hf-inlined-src. Body scripts still go throughdeferScriptsUntilFonts, so an inlineddeferfile waits for the runtime gate.DEFERRED_FILEthen runs it after the classic scripts. runScriptsAfterFonts: it now runs the late scripts in the same loop as the others and waits for each external script to load. That is what makes an inline deferred file run after a deferred CDN gsap. An inline script inserted later would otherwise run straight away, not in queue order.loaded()also resolves onerror, so a CDN that fails to load can't stall the queue.isClassicInline: withnomoduleexcluded, a legacy-only body script is no longer given the after-fonts type, so modern browsers skip it again.- Escaping merged runs: applying
escapeInlineScriptSourcetwice changes nothing. Escaped text no longer contains</scriptor<!--, so inlining a file and then merging it is safe.\x3C!--means the same thing inside strings, template literals and regexes, character classes included. - Runtime strip: every writer of a lasting runtime tag sets the bootstrap attribute. That covers the bundler's three modes, the preview route and the sub-composition preview. A placeholder
src=""bundle is therefore stripped, and the preview runtime goes in its place. - Render path:
inlineExternalScriptsskipping a runtime file link keeps the file server's strip in charge. Render CDN inlining keepsdeferwithout the inlined-file mark, so it doesn't matchDEFERRED_FILEand runs as before.
Reuse:
insertRuntimeTagtakes the bundler's placement code and gives it to the preview route.- The two "is a runtime already here?" text checks are deleted rather than copied.
escapeInlineScriptSourceandisRuntimeFileUrlmove from private helpers to exports.- The bundler and producer share
inlineScriptRunsandisClassicInline.
I found nothing else this should have been built on.
Simplicity: the bundler's local-JS step gets shorter, since a three-line inline-in-place replaces the anchor and join step. I don't see a smaller fix for both the order and the attributes.
Verified locally:
- These suites pass:
- core's
htmlBundler,htmlDocument,scriptRunsandentry.afterFonts: 221 tests - studio-server's
subCompositionandpreview: 108 tests - producer's
htmlCompiler, under bun: 140 tests - the producer test-classification check
- core's
- I made six mutations, and each one turned tests red:
- defer scripts merging into runs again: 1 fails
nomodulecounting as classic: 2 failDEFERRED_FILEwithout the inlined mark: 3 fail- the late scripts never running: 2 fail
- inlined files left unescaped: 1 fails
- the
__hyperframeRuntimetext-marker strip restored: 2 fail
Non-blocking:
- A runtime pasted inline with no bootstrap attribute now survives the strip. That's a copy of the runtime body inside a plain
<script>. The old text markers caught it, so Studio preview andserialize({ stripRuntime: true })would now load a second runtime for such a page. I found no writer or fixture in the repo that produces one; the producer's attribute-less inline runtime is only added when the page is served. Worth a line in the known limits. - Late module scripts with a
srcnow load one after another instead of together. Order and results are unchanged, so it only costs time on pages with several external modules.
— Rames
somanshreddy
left a comment
There was a problem hiding this comment.
Review at b8516ff9 (full PR).
This is a comment, not an approval. Rames already covered the deferred-after-CDN order, nomodule, double escaping, the attribute-based strip, the inline runtime pasted without the attribute, and late external modules loading one at a time. I don't repeat those here.
Order matrix (real Chrome 152, raw page vs the head's bundle + runtime). 15 pages: local and CDN scripts, classic, defer, async, async defer, inline and src modules, nomodule, text/jscript, head and body, interleaved, plus 404, throwing, CSP-blocked and hanging scripts. 6 mismatches:
- a local
deferfile in<head>runs during parsing: known limit. - a body inline script runs after a
<head>CDNdeferscript: this comes from the #4980 gate and is the same on the base. - a local
asyncand a localasync deferfile run at a different moment: allowed, sinceasynchas no order guarantee. - under an inline-only CSP, the inlined local
deferfile runs and the raw page blocks it: intended. - an inline module followed by a local
deferfile: bug, finding 2.
On the base bundle, P1 put 4 of 10 scripts in the wrong place, and P4 ran setup, main, tail, gsap. Both match the raw page at this head. The pre-#4980 runtime's fallback gives the same order as the head runtime.
1. (medium) A local file that uses <!-- as a legacy comment now breaks every script merged with it, including the scripts before it. htmlBundler.ts:1231 escapes the file to \x3C!-- before the merge at :741 runs esbuild (stripJsCommentsParserSafe, :793-799). esbuild can't parse the escaped text, so the catch returns the source unchanged and the whole merged script is one SyntaxError. In the probe, esc.js, legacy.js, two authored inline scripts and plain.js sit next to each other. At this head none of the five ran ("Invalid or unexpected token"). On the base all five ran. An authored inline script with the same comment still works, because esbuild strips the comment before the escape. Known limits says only the legacy file stops parsing. Fix: run the comment strip before the escape at :1231, or escape once, after the merge.
2. (low) An inline module followed by a deferred local file runs in the wrong order (afterFonts.ts:116-118). The inline module has no src, so nothing waits for it. The inlined file is a classic inline script, so it runs as soon as it is inserted, before the module. Raw page: m1, then d with sawModule=true. Bundled: d with sawModule=false, then m1. Same result through the fallback. This is no worse than the base, which ran the file even earlier, but it doesn't match the "document order" claim. One fix is a barrier after an inline module when another deferred script follows. Codex flagged this first; I confirmed it in Chrome.
3. (low) The escape changes some values. String.raw\<!--`becomes\x3C!--andString.raw`</script>`becomes</script>. "</SCRIPT >"becomes"</script >", because escapeCaseInsensitiveToken (htmlDocument.ts:170-190) writes the replacement in lower case. The base kept all three. The merge path (:741`) applies this to authored inline scripts too. Keeping the matched case fixes the last one; the raw-string cases belong in Known limits. Codex found the same thing on its own.
Liveness. Each wait resolves on load or error. A 404, a script that throws, and a CSP-blocked script all let the chain go on. A CDN defer script that never answers stops it for good: the runtime never boots and __renderReady stays unset. That is also what the raw page does (DOMContentLoaded never fires) and what the base does. There is no timeout, so a render stops only at the engine's 45 s player-ready limit. A hanging async script doesn't block. Worth a line in Known limits.
#4980 and #5007. My #4980 finding 1 is still there: with the pre-#4980 runtime, a deferred main.js that runs after a slow deferred gsap sees __playerReady and __renderReady already true. With the head runtime both are false. Findings 2 and 3 are in files this PR doesn't change. #5007 works with this PR: its plugin tag copies gsap's type and defer, so both go through the gate as deferred and the plugin runs after gsap. The async gsap case is still open.
Codex leads I did not keep. First, async defer treated as deferred (scriptRuns.ts:71). async allows any position, so this is not a bug. Second, the sub-composition runtime moving to after the head scripts. The base also stripped the runtime and added it again at the end (subComposition.ts:350-352), so its position is unchanged.
Render path. The repo's registry, examples, skills, producer tests and CLI use no defer, async or nomodule scripts, and no script type that the new MIME list changes. The scripts the render server adds to <head> contain none of the old text markers. The runtime delta adds no src, data: or blob:.
Mutations: 7 of 7 caught. Dropping defer/async from the merge guard, cutting the MIME list, making only modules late in the runtime, the same in the fallback, not waiting for late src scripts, putting the preview runtime before </body>, and letting the producer inline the runtime link.
CI: 84 pass, 8 skipped, 0 failed; 11 of 11 required checks pass.
What I ran: the Chrome order and escape probe against the head, the base, and the pre-#4980 runtime. The changed core tests, subComposition, bundleScriptPolicy (Chrome) and the producer htmlCompiler tests all pass, run under vitest or bun. In preview.test.ts I ran only the 9 runtime tests, under bun, because vitest won't load the file here (the node: issue). One static Codex pass finished within its time box, and I checked each of its leads at source.
— Somu
jrusso1020
left a comment
There was a problem hiding this comment.
Changing my approval to request changes. The comment review posted at this same head found a regression that I missed, and I reproduced it.
Blocker: a local script that uses <!-- as a legacy comment now stops every script merged with it from running.
htmlBundler.ts:1231writes each inlined local file throughescapeInlineScriptSource, which turns<!--into\x3C!--.- Then
:741joins the run and passes it tostripJsCommentsParserSafe. As code,\x3C!--is invalid, so esbuild throws, the catch returns the source unchanged, and the browser gets a block that won't parse. None of the scripts in that run execute, including the ones before the legacy file. - Probe at this head:
window.first = 1;merged with a file containing<!-- hide\nwindow.legacy = 2;\n// -->. The output failsnew vm.Scriptwith "Invalid or unexpected token". Joining the raw sources, then stripping and escaping once, parses fine, and so does the raw source (it's an Annex B HTML comment).
The smallest fix I see: strip comments at :1231 before escaping, i.e. escapeInlineScriptSource(stripJsCommentsParserSafe(js)). After esbuild, any <!-- left over is inside a string, regex or template, where \x3C is valid, so the strip at :741 parses it and the escape there has nothing left to change. Scripts that run separately (deferred, async, head) stay escaped exactly as they are now. Please add a bundler test with a legacy-comment file next to another local script, asserting that both run.
The other reviewer's low-severity notes (an inline module before a deferred local file, the String.raw value change, a CDN defer that never responds) don't block from my side, and I haven't verified them. Everything else in my earlier approval still holds, and I'll re-approve once the fix lands.
— Rames
…un, and escapes keep case
jrusso1020
left a comment
There was a problem hiding this comment.
Re-review at 6a7f72cf (one commit on top of b8516ff9). Approving. The blocker from my last review is fixed.
The legacy-comment blocker is fixed. Local files now stay raw when they're inlined (htmlBundler.ts:1231). A merged run is stripped by esbuild and then escaped once (:741). Inlined files that were left unmerged get one escape pass after coalesceHeadStylesAndBodyScripts (:1265). So every tag is escaped exactly once, and esbuild only ever sees raw source. Nothing between :1231 and :1265 serializes or re-parses the script text. The new bundler test runs the five-script case in order and checks that the merged text has no </script or <!--.
The other two changes check out:
afterFonts.ts:119now waits for the module queue after each inline module, not once at the end. A later deferred file therefore sees the module's globals, and the new Chrome test covers that. A module whose import never resolves already held up the end of the chain before this change, so this adds no new stall.- The escape keeps the case it matched (
</SCRIPTbecomes<\/SCRIPT).
Evidence:
- core
htmlBundler,htmlDocument,scriptRunsand runtime suites: 72 files, 2044 tests, all pass. - producer
bundleScriptPolicy.test.tsin Chrome: 3 of 3 pass. - I made four mutations, and a test failed for each one:
- escaping again at
:1231 - removing the final escape pass
- lowercasing the escape again
- moving the module wait back to the end of the chain (fails the new Chrome test)
- escaping again at
Reuse / simplicity: the fix reuses the existing escape helper and the existing post-merge step. It's smaller than the code it replaces, and I don't see a simpler version.
Non-blocking: Known limits says a file that's inlined on its own (defer, async, nomodule) and uses a legacy <!-- comment stops parsing. On main, such a file was merged, so its comment was stripped, which makes this a narrow regression. If you want to close it, the final pass could run stripJsCommentsParserSafe before escaping, limited to JavaScript types so that text/babel and other non-JS types are untouched. It's fine as a follow-up.
— Rames
jrusso1020
left a comment
There was a problem hiding this comment.
Re-review at 53506c9c, which adds two commits on top of 6a7f72cf, the head I approved. Approving.
What changed: the last escape pass now strips comments before escaping, but only on JavaScript types, through the new isJavaScriptType helper (htmlBundler.ts:1266-1270). That closes the non-blocking case from my last review: a file inlined on its own (in <head>, or defer in <body>) with a legacy <!-- comment. Non-JavaScript types such as JSON and text/babel keep their text as written. The type check is the one isClassicInline already uses, now factored out, so there's no second list of types to keep in sync.
Evidence:
- The core compiler and runtime suites pass (84 files, 2337 tests), and the producer
bundleScriptPolicyChrome tests pass 3 of 3. - Removing the strip fails both new
it.eachcases. - Stripping every type fails the new JSON test, because esbuild rewrites
[1,2].
Reuse / simplicity: the fix reuses the existing strip helper and the existing type set, and it's four lines. I don't see a smaller fix.
— Rames
What
Scripts in a Studio preview now run where and when the author put them:
layout,motion-shot,validate,compareandgrade-compare) keep their place,defer,async,nomoduleandtype. A<script defer src="main.js">after a deferred gsap from a CDN runs after gsap again, so the composition builds its timeline instead of failing withgsap is not defined. This holds under a page security policy that allows inline scripts only.window.__hyperframeRuntime(or mentions a runtime file name, or sets upwindow.__player) is no longer deleted from the preview and the bundle.Why
The bundler joined every local
<script src>file into one inline script at the position of the first one. That:deferandasync(an inline script cannot be deferred), so a deferredmain.jsran during parsing, before a deferred gsap;setup.js, gsap CDN,main.jsbecamesetup.js + main.js, then gsap).The step that later merges adjacent inline scripts also folded in
nomodulescripts, dropping the attribute, so legacy-only code ran in modern browsers. It only treated three type strings as JavaScript, so a script typed with another JavaScript MIME type the browser runs (application/ecmascript,text/jscript, ...) skipped the web-font gate and ran early.Runtime stripping matched words in a script's text (
__hyperframeRuntime,window.__player =, ...), so it deleted authored scripts that used them.The preview's no-bundle path placed the runtime before
</body>, after the composition's scripts; the bundler-threw path added none.Related work
Same family as #5007 (Studio's MotionPathPlugin placement after a deferred or query-string gsap); different owner, so a separate change.
How
bundleProject(core/src/compiler/htmlBundler.ts): each local classic script is inlined in its own tag, keeping its other attributes (defer,async,nomodule, a non-JavaScripttype) and markeddata-hf-inlined-srcwith the file it came from. Module and integrity-pinned scripts are untouched, as before. Every inlined local JavaScript file now goes through the same esbuild comment strip that merged runs always had (main already did this for files merged into a body script): comments, license banners and source-map comments are dropped andfn.toString()text changes, behaviour does not. Inlined text and merged runs go through core'sescapeInlineScriptSource, so a file containing</script>or<!--cannot end or swallow its tag;<!--is now written\x3C!--, which stays valid inside a unicode regex.inlineScriptRuns/isClassicInline(core/src/compiler/scriptRuns.ts), shared by the bundler and the producer's render compiler: adefer,asyncornomodulescript is never merged into a run, and every JavaScript MIME type from the HTML spec counts as classic JavaScript.runScriptsAfterFonts(core/src/runtime/afterFonts.ts), which already runs a bundled page's body scripts once fonts are ready, and its fallback for older runtimes: a deferred file (deferwith asrc, or with the inlined-file mark) runs after the classic scripts, in document order, and each external script finishes loading before the next script runs. That is the browser's order for the authored page, so nodata:URL or other source is needed. An authored inline<script defer>keeps running in place, as browsers run it.stripEmbeddedRuntimeScripts(core/src/compiler/htmlDocument.ts): a script is the runtime's own when its start tag carries the bootstrap attribute or itssrcpath ends in a runtime file name, plus the existing whole-script readiness flags; text inside an authored script no longer counts. Every writer of a lasting runtime tag (bundler, Studio preview, sub-composition preview) sets the attribute. The two "is a runtime already here?" text checks that followed the strip (bundler, sub-composition preview) are gone: after the strip none is left, and the text check skipped the runtime when an authored script mentioned its attribute. The render compiler's CDN inliner (inlineExternalScripts) leaves a runtime file link alone, so the render file server still strips it instead of booting a second, unpinned runtime.insertRuntimeTag(core/src/compiler/htmlDocument.ts): the bundler's runtime placement (end of<head>, else after<html>or the doctype), moved out ofinjectInterceptor. The Studio preview applies its runtime through it on every path: it strips any runtime already in the page and inserts the preview runtime with the bootstrap attribute.Adjacent plain inline scripts, including inlined local files with nothing between them, are still merged into one by the existing step, as before; a throw in one stops the rest of that merged script. Files separated by any other script stay separate.
Known limits, unchanged or accepted: a deferred local file in
<head>is inlined there and still runs during parsing, as before this change, so a page that needs it to run after a deferred CDN script in<head>still fails (scripts in<body>work); a follow-up handles head order.String.rawtext containing<!--in a bundled script reads back as\x3C!--; in a local non-JavaScript file inlined on its own (JSON, a template,text/babel), every<!--and</scriptis escaped (\x3C!--,<\/script), which its reader does not undo, so such text in a JSON string stops it parsing (main already turned these files into JavaScript). A deferred CDN script that never answers stops the scripts after it, as in the raw page (the engine's player-ready limit still ends a render). A runtime pasted inline without the bootstrap attribute is no longer recognised and would load twice; nothing in the repo writes one. Studio preview loads deferred external scripts and modules one after another, as it already did for classic ones. The fallback for runtimes older than the font gate does not wait for an inline module before a later deferred file. Studio preview always serves its own runtime, also for a bundle built withHYPERFRAME_RUNTIME_URL.Render path
Checked with real renders (CLI, Chrome 152) of four shapes on
mainand on the web-font gate build: deferred gsap + deferred localmain.js, localsetup.js/ CDN gsap / localmain.js, deferred gsap +DOMContentLoadedlistener, async gsap +DOMContentLoadedlistener (3 runs). All rendered the tween correctly at t = 1 s on both builds; a reversed control (main.js before gsap) stayed static. The render compiler inlines CDN scripts in place and keeps theirdefer, without the inlined-file mark, so renders run them exactly as before this change. It reaches the render path throughisClassicInline(nomoduleand the JavaScript type list), the stricter runtime strip, and the runtime-link skip above. No template, fixture or skill in the repo usesdeferscripts.Test plan
htmlBundler.test.ts(script order): a local file around a CDN script keeps its place; a localdeferand a localasyncfile are inlined in their own tag with the attribute;nomodulesurvives on a local file and on an inline script; a localtype="text/babel"file keeps its type; a local file containing</script>and<!--is escaped, merged and unmerged; a unicode regex matching<!--still compiles; a run merging a local file that has a legacy<!--comment with authored scripts and other files stays valid and runs all five in order; a local file with a legacy<!--comment inlined on its own (in<head>, or deferred in<body>) stays valid; a local non-JavaScript file keeps its text as written; an authored script readingwindow.__hyperframeRuntimesurvives; the runtime is still added when an authored script mentions its attribute.entry.afterFonts.test.ts: an inlined deferred file runs after a later classic script, through the runtime and through the old-runtime fallback, while an authored inline defer script runs in place, through both; an inline script after an external one runs once the external one has loaded, with and withoutdefer.htmlDocument.test.ts: the escape keeps the matched case (</SCRIPT>becomes<\/SCRIPT>).scriptRuns.test.ts: four legacy JavaScript types join a run;text/javascript2does not.subComposition.test.ts: the preview runtime is added when the project head's script names a runtime file. ProducerhtmlCompiler.test.ts: a CDN runtime link is not inlined.htmlDocument.test.ts: authored scripts that read the runtime global, query the bootstrap attribute, name a runtime file or set upwindow.__playerare kept; a runtime file linked with a query string or uppercase name is stripped.preview.test.ts: when the bundler returns nothing, returns a page without a runtime, or throws, the runtime loads before a composition script that callsgsap.set(); a disk page that already links a runtime gets exactly one; an authored script reading the runtime global survives.bundleScriptPolicy.test.ts(producer integration, real Chrome): a bundled page withscript-src 'unsafe-inline'runs its local defer script after the classic script and animates (CSS animation at 1 s after a seek). Fails on the previousdata:URL version with a policy violation. A second Chrome test serves a deferred CDN script through request interception and checks that the inlined deferredmain.jssees it; a third checks that a deferred local file runs after an inline module before it.src-only (runtime, fallback and Chrome tests red), merging defer/async, the old<\!--escape, no runtime on the bundled path, text-based stripping; each turns its own test red.Before
Studio preview, Chrome 152: deferred gsap from a CDN, then
<script defer src="main.js">that builds the timeline. The bundledmain.jsruns before gsap:Uncaught ReferenceError: gsap is not defined, no timeline, the dot never moves.After
Same project with this change, seeked to 1 s through the runtime:
main.jsis inlined withdeferkept and runs after gsap, themaintimeline exists and the dot is half-way across. 3 of 3 runs.