Skip to content

Commit e1b6093

Browse files
fix(core): a data script in a sub-composition no longer stops every timeline (#5205)
* fix(core): a data script in a sub-composition no longer stops every timeline A sub-composition's <script type="application/json"> was wrapped and run as JavaScript, and because the bundle merges every composition script into one, its parse error stopped every scene's timeline. Data scripts now stay in the DOM as authored, and a script run that does not parse is left unmerged so a bad script fails alone. * fix(core): render and preview keep a broken composition script from stopping the rest The render path's script merge had the same gap as the bundler: one script that does not parse stopped every timeline. Both now share one parse check. A run left unmerged, and every scene-part script, is still escaped, so a string holding "</script>" can no longer end the script early in preview. * refactor(core): carry the bundler's script-merge reasons in names, not comments * fix(core): every composition path runs only the scripts a browser would A script check now matches the browser (esbuild accepted export and top-level await), so such a script can no longer stop every timeline. Inline template compositions keep data scripts and run module and import map scripts as such. A data script beside the composition root is mounted next to it, a nomodule script never runs, and the render path merges scripts on a page with no head. * fix(core): keep inline-template data scripts and run a script with a top-level return alone - parsesAsScript compiles with vm.Script, so a top-level return or new.target no longer passes as mergeable and stops every timeline. - The bundler's inline <template id="X-template"> path asks the shared assembly plan for its root and mounts the inert scripts authored beside it. - nomodule only stops classic scripts; type="module" nomodule runs, as in browsers. * refactor(core): the inline template path reads its root id from the shared assembly plan * fix(core): a broken composition script stays apart under Bun as well as Node parsesAsScript now parses with acorn in script mode instead of vm.Script. Bun 1.3's vm.Script compiles lazily and accepted any text, so the Studio dev server merged a broken script with the rest and every timeline stopped. acorn is already locked for parsers and lint; core now declares it. * test(core): name the parse-check test for what it pins * style(core): wrap the data-block comment under the line limit
1 parent 5ec2dd2 commit e1b6093

13 files changed

Lines changed: 538 additions & 61 deletions

‎bun.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/core/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,7 @@
902902
"@hyperframes/lint": "workspace:*",
903903
"@hyperframes/parsers": "workspace:*",
904904
"@hyperframes/studio-server": "workspace:*",
905+
"acorn": "^8.17.0",
905906
"bpm-detective": "^2.0.5",
906907
"linkedom": "^0.18.12",
907908
"postcss": "^8.5.8",

‎packages/core/src/compiler/compositionAssembly.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,14 @@ describe("planCompositionAssembly", () => {
126126
expect(ids(plan.scriptSources)).toEqual(["head-script", "body-script"]);
127127
});
128128

129+
it.each([
130+
[`<script nomodule></script>`, false],
131+
[`<script type="module" nomodule></script>`, true],
132+
])("extracts %s only when a browser runs it: %s", (script, runs) => {
133+
const plan = planFor(`<template><div data-composition-id="s">${script}</div></template>`, "s");
134+
expect(plan.scriptSources).toHaveLength(runs ? 1 : 0);
135+
});
136+
129137
it("does not treat a templated composition's page head as an asset source", () => {
130138
// The head belongs to the host page, not to the composition; only the
131139
// non-templated (full-document) shape carries composition assets in <head>.

‎packages/core/src/compiler/compositionAssembly.ts‎

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,64 @@ const SCRIPT_SELECTOR = "script";
3434
*/
3535
const HOISTED_LINK_SELECTOR = 'link[rel="stylesheet"], link[rel="preconnect"]';
3636

37-
export const EXTRACTED_COMPOSITION_ASSET_SELECTOR = `${STYLE_SELECTOR}, ${SCRIPT_SELECTOR}, ${HOISTED_LINK_SELECTOR}`;
37+
// The HTML spec's JavaScript MIME type essence matches: a script with any of these types runs as classic.
38+
const JAVASCRIPT_TYPES = new Set([
39+
"",
40+
"application/ecmascript",
41+
"application/javascript",
42+
"application/x-ecmascript",
43+
"application/x-javascript",
44+
"text/ecmascript",
45+
"text/javascript",
46+
"text/javascript1.0",
47+
"text/javascript1.1",
48+
"text/javascript1.2",
49+
"text/javascript1.3",
50+
"text/javascript1.4",
51+
"text/javascript1.5",
52+
"text/jscript",
53+
"text/livescript",
54+
"text/x-ecmascript",
55+
"text/x-javascript",
56+
]);
57+
58+
const scriptType = (el: AssemblyAttributed) => (el.getAttribute("type") || "").trim().toLowerCase();
59+
60+
export function isJavaScriptType(el: AssemblyAttributed): boolean {
61+
return JAVASCRIPT_TYPES.has(scriptType(el));
62+
}
63+
64+
/**
65+
* A data block (application/json, text/template, ...) or a classic nomodule script never runs,
66+
* so it is mounted as authored.
67+
*/
68+
function isExecutableScript(el: AssemblyAttributed): boolean {
69+
const type = scriptType(el);
70+
if (type === "module" || type === "importmap") return true;
71+
return JAVASCRIPT_TYPES.has(type) && el.getAttribute("nomodule") === null;
72+
}
73+
74+
function inertScripts<TElement extends AssemblyAttributed>(
75+
node: AssemblyQueryable<TElement> | null | undefined,
76+
): TElement[] {
77+
return toArray(node?.querySelectorAll(SCRIPT_SELECTOR)).filter((el) => !isExecutableScript(el));
78+
}
79+
80+
export function executableScripts<TElement extends AssemblyAttributed>(
81+
node: AssemblyQueryable<TElement> | null | undefined,
82+
): TElement[] {
83+
return toArray(node?.querySelectorAll(SCRIPT_SELECTOR)).filter(isExecutableScript);
84+
}
85+
86+
/** The styles, scripts and links both paths extract, so the mounted copy must drop them. */
87+
export function extractedCompositionAssets<TElement extends AssemblyAttributed>(
88+
node: AssemblyQueryable<TElement>,
89+
): TElement[] {
90+
return [
91+
...toArray(node.querySelectorAll(`${STYLE_SELECTOR}, ${HOISTED_LINK_SELECTOR}`)),
92+
...executableScripts(node),
93+
];
94+
}
3895

3996
/**
4097
* The compiler's nesting cap, enforced against the ancestry chain rather than a
@@ -136,6 +193,9 @@ export interface CompositionAssemblyPlan<TElement extends AssemblyAttributed> {
136193
/** Head and content links to hoist into the host document. */
137194
linkSources: TElement[];
138195

196+
/** Inert scripts the mounted root would leave behind; both paths mount them next to it. */
197+
inertScriptsOutsideRoot: TElement[];
198+
139199
/**
140200
* Nodes that may declare the composition's variable defaults, in precedence
141201
* order — later wins. Full-document compositions declare on `<html>`;
@@ -159,9 +219,9 @@ function toArray<TElement>(items: Iterable<TElement> | null | undefined): TEleme
159219
* how it is identified. Pure: it reads attributes and runs selectors, and does
160220
* not mutate, fetch, or touch a filesystem.
161221
*/
162-
export function planCompositionAssembly<TElement extends AssemblyAttributed>(
163-
input: CompositionAssemblyInput<TElement>,
164-
): CompositionAssemblyPlan<TElement> {
222+
export function planCompositionAssembly<
223+
TElement extends AssemblyAttributed & AssemblyQueryable<TElement>,
224+
>(input: CompositionAssemblyInput<TElement>): CompositionAssemblyPlan<TElement> {
165225
const { contentNode, head, documentElement, hasTemplate, compositionId } = input;
166226

167227
const compositionRoots = toArray(contentNode.querySelectorAll(COMPOSITION_ROOT_SELECTOR));
@@ -177,6 +237,7 @@ export function planCompositionAssembly<TElement extends AssemblyAttributed>(
177237

178238
// A templated composition's <head> belongs to its host page, not to it.
179239
const assetHead = hasTemplate ? null : (head ?? null);
240+
const mountedInert = innerRoot ? inertScripts(innerRoot) : [];
180241

181242
return {
182243
innerRoot,
@@ -187,14 +248,15 @@ export function planCompositionAssembly<TElement extends AssemblyAttributed>(
187248
...toArray(assetHead?.querySelectorAll(STYLE_SELECTOR)),
188249
...toArray(contentNode.querySelectorAll(STYLE_SELECTOR)),
189250
],
190-
scriptSources: [
191-
...toArray(assetHead?.querySelectorAll(SCRIPT_SELECTOR)),
192-
...toArray(contentNode.querySelectorAll(SCRIPT_SELECTOR)),
193-
],
251+
scriptSources: [...executableScripts(assetHead), ...executableScripts(contentNode)],
194252
linkSources: [
195253
...toArray(head?.querySelectorAll(HOISTED_LINK_SELECTOR)),
196254
...toArray(contentNode.querySelectorAll(HOISTED_LINK_SELECTOR)),
197255
],
256+
inertScriptsOutsideRoot: [
257+
...inertScripts(assetHead),
258+
...(innerRoot ? inertScripts(contentNode).filter((el) => !mountedInert.includes(el)) : []),
259+
],
198260
variableDefaultCarriers: [documentElement, innerRoot].filter(
199261
(carrier): carrier is TElement => carrier != null,
200262
),

0 commit comments

Comments
 (0)