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 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}`; }