From b6ff3ab74556b844dd87b067cf850badc000bba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 8 Aug 2026 13:07:10 -0700 Subject: [PATCH] fix: preserve the composition query and serve the runtime before author scripts (#3114) * fix(player): stop re-encoding the composition query Every src the player sets goes through withShaderQueryParams, which parsed the author's whole query with URLSearchParams and re-serialised it with toString(). That is a form encoder: it writes a space as +, while callers percent-encode and read back with decodeURIComponent. Those two codecs are not inverses, so any space in any query value arrived corrupted. It ran even when there was nothing to inject. With no shader attributes both params are deleted, so the round-trip was pure loss, on every src, for every consumer. Append the two params to the raw query instead of re-serialising it. The player now hands a composition its query back byte-identical. Empirically space was the only casualty: plus, ampersand, equals, hash, percent, question mark, quotes and non-ASCII all survived a URLSearchParams round-trip. That is narrow, but a space in a headline or in SVG path data is the common case, and invalid path data renders nothing at all. Latent until now: no shipped consumer depended on query preservation, so this surfaced only once compositions began carrying variable payloads. * fix(cli): serve the runtime ahead of every author script injectRuntime appended its script before , so it landed after any inline script the composition carried. At the moment a composition's own script ran, window.__hyperframes was undefined and getVariables() was unreachable: our documented API did not exist at the point authors are told to call it. Served order was gsap at line 6, the composition's init script at 20, the runtime at 37. A probe inside the composition's IIFE recorded hfTypeAtInit undefined with no variable keys, and the element rendered its hardcoded fallback rather than the declared value. The runtime is designed to load early. Its entry assigns __timelines, installs the authored-opacity capture (whose own comment says it must run while the document is still parsing), and exposes __hyperframes synchronously, deferring real work to DOMContentLoaded. End-of-body injection defeated all three, and nothing in it needs a parsed DOM, so no defer is wanted. Injects at head start instead, reusing the placement cascade injectScriptsAtHeadStart already implemented rather than adding a fourth copy of it. Head start rather than the closing tag so the runtime also precedes author scripts inside head. injectRuntime has exactly one consumer, the play server's composition route. Every other surface reaches the runtime through the bundler, which already injects into head, or deliberately serves raw. Two registry blocks had independently worked around this by parsing the authored attribute themselves. Those stay, but the workaround is no longer the only way to read a variable at init. --- packages/cli/src/commands/play.test.ts | 25 +++++++++++++++++ packages/cli/src/utils/compositionServer.ts | 16 +++++++---- packages/core/src/compiler/htmlDocument.ts | 20 +++++++++---- packages/core/src/compiler/index.ts | 1 + packages/player/src/shader-options.ts | 31 ++++++++++++++++----- 5 files changed, 75 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/commands/play.test.ts b/packages/cli/src/commands/play.test.ts index fda0351e6..f4fec2413 100644 --- a/packages/cli/src/commands/play.test.ts +++ b/packages/cli/src/commands/play.test.ts @@ -251,6 +251,31 @@ describe("registerCompositionRoute", () => { expect(mocks.resolveProxy).not.toHaveBeenCalled(); }); + it("serves the runtime script ahead of every author script", async () => { + // Compositions read `window.__hyperframes.getVariables()` from an inline + // script at init. A runtime injected before loads after that script, + // so the documented API is undefined exactly where authors are told to call + // it. Pin the ordering at the served-document boundary. + const project = tmpProject(); + writeFileSync( + join(project.dir, "index.html"), + [ + '', + '
', + "", + "", + ].join(""), + ); + const app = await buildApp(project, false); + + const html = await (await app.request("/composition/index.html")).text(); + + const runtimeIndex = html.indexOf('`; - return html.includes("") - ? html.replace("", `${runtimeTag}\n`) - : html + `\n${runtimeTag}`; + return injectTagsAtHeadStart(html, ``); } export function assetContentType(filePath: string): string { diff --git a/packages/core/src/compiler/htmlDocument.ts b/packages/core/src/compiler/htmlDocument.ts index 6d2e84da5..b004a784c 100644 --- a/packages/core/src/compiler/htmlDocument.ts +++ b/packages/core/src/compiler/htmlDocument.ts @@ -174,16 +174,24 @@ function inlineScriptTags(scripts: readonly string[]): string { return scripts.map((source) => ``).join("\n"); } -export function injectScriptsAtHeadStart(html: string, scripts: readonly string[]): string { - if (scripts.length === 0) return html; - const headTags = inlineScriptTags(scripts); +/** + * Insert raw tag markup at the very start of ``, ahead of every author + * script (inline or external). Falls back to just before ``, then to the + * top of the document, for fragments that carry neither. + */ +export function injectTagsAtHeadStart(html: string, tags: string): string { if (html.includes("]*>/i, (match) => `${match}\n${headTags}`); + return html.replace(/]*>/i, (match) => `${match}\n${tags}`); } if (html.includes(" `${headTags}\n `${tags}\n pair !== "" && pair.split("=")[0] !== key); } +/** + * The player's own params, appended to the query the composition author wrote + * rather than merged into a re-serialized copy of it. + * + * `new URLSearchParams(query).toString()` is a form-encoding round trip: it + * re-encodes the *whole* query as application/x-www-form-urlencoded, which + * writes every space as `+`. A composition reading its own query with + * `decodeURIComponent` — percent-decoding, which leaves `+` alone — cannot undo + * that, so a value of "Ship it today" arrived on the page as "Ship+it+today". + * The two codecs are not inverses, and the player has no business picking one + * for a query it is only passing along. The author's bytes now travel through + * byte-identical; only our two keys are rewritten. + */ function withShaderQueryParams( src: string, scale: string | null, @@ -76,10 +90,13 @@ function withShaderQueryParams( const queryIndex = beforeHash.indexOf("?"); const path = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash; const query = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : ""; - const params = new URLSearchParams(query); - setQueryParam(params, SHADER_CAPTURE_SCALE_PARAM, scale); - setQueryParam(params, SHADER_LOADING_PARAM, loadingMode === "composition" ? null : loadingMode); - const nextQuery = params.toString(); + let pairs = withoutParam(query.split("&"), SHADER_CAPTURE_SCALE_PARAM); + pairs = withoutParam(pairs, SHADER_LOADING_PARAM); + if (scale !== null) pairs.push(`${SHADER_CAPTURE_SCALE_PARAM}=${encodeURIComponent(scale)}`); + if (loadingMode !== "composition") { + pairs.push(`${SHADER_LOADING_PARAM}=${encodeURIComponent(loadingMode)}`); + } + const nextQuery = pairs.join("&"); return `${path}${nextQuery ? `?${nextQuery}` : ""}${hash}`; }