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"),
+ [
+ '
',
+ '