From 219c644f2759b236693bd1077e18fa7954750134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 8 Aug 2026 16:24:28 +0000 Subject: [PATCH 1/2] 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. --- packages/player/src/shader-options.ts | 31 +++++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/player/src/shader-options.ts b/packages/player/src/shader-options.ts index ef7b4079ff..1b59bc786a 100644 --- a/packages/player/src/shader-options.ts +++ b/packages/player/src/shader-options.ts @@ -60,11 +60,25 @@ function normalizeShaderLoadingMode(value: string | null): ShaderLoadingMode { return "composition"; } -function setQueryParam(params: URLSearchParams, key: string, value: string | null): void { - if (value === null) params.delete(key); - else params.set(key, value); +/** Drop one of our own keys from raw `a=1&b=2` pairs, matched by name so that + * nothing around it has to be decoded. */ +function withoutParam(pairs: string[], key: string): string[] { + return pairs.filter((pair) => 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}`; } From 10633f2fe9e5c14fa5d3c0c45dc69a8dc599c5da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 8 Aug 2026 17:47:09 +0000 Subject: [PATCH 2/2] 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 + 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/play.test.ts b/packages/cli/src/commands/play.test.ts index fda0351e6d..f4fec2413c 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 6d2e84da50..b004a784cc 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