fix(studio-server): a project's signature no longer freezes the host while it walks the files (#7073)

* fix(studio-server): a project's signature no longer freezes the host while it walks the files

* style(studio-server): format the signature changes

* fix(studio-server): keep the adapter for the preview's early-error script
This commit is contained in:
Miguel Ángel
2026-11-03 09:16:40 +00:00
committed by GitHub
parent 9ceced05e2
commit ce9830fece
8 changed files with 69 additions and 35 deletions
@@ -67,46 +67,46 @@ describe("a root write", () => {
const rewrite = (dir: string, head: string, body: string) =>
writeFileSync(join(dir, "index.html"), html(head, body));
it("moves every composition when the head changed, or when no head was seen yet", () => {
it("moves every composition when the head changed, or when no head was seen yet", async () => {
const dir = project(files("<style>a{}</style>", "x"));
expect(compositionsAffectedBy(dir, "index.html")).toBeNull();
compositionInputSignature(dir, "compositions/a.html", "s1");
await compositionInputSignature(dir, "compositions/a.html", "s1");
rewrite(dir, "<style>b{}</style>", "x");
expect(compositionsAffectedBy(dir, "index.html")).toBeNull();
});
it("moves only the root when the head is as it was", () => {
it("moves only the root when the head is as it was", async () => {
const dir = project(files("<style>a{}</style>", "x"));
compositionInputSignature(dir, "compositions/a.html", "s1");
await compositionInputSignature(dir, "compositions/a.html", "s1");
rewrite(dir, "<style>a{}</style>", "moved");
expect(compositionsAffectedBy(dir, "index.html")).toEqual(["index.html"]);
rewrite(dir, "<style>a{}</style>", "moved again");
expect(compositionsAffectedBy(dir, "index.html")).toEqual(["index.html"]);
});
it("tells every subscriber of one head edit the same thing, even after a thumbnail request", () => {
it("tells every subscriber of one head edit the same thing, even after a thumbnail request", async () => {
const dir = project(files("<style>a{}</style>", "x"));
compositionInputSignature(dir, "compositions/a.html", "s1");
await compositionInputSignature(dir, "compositions/a.html", "s1");
rewrite(dir, "<style>b{}</style>", "x");
expect(compositionsAffectedBy(dir, "index.html")).toBeNull();
compositionInputSignature(dir, "compositions/a.html", "s2");
await compositionInputSignature(dir, "compositions/a.html", "s2");
expect(compositionsAffectedBy(dir, "index.html")).toBeNull();
});
it("leaves a scene's input signature alone for a body edit, not for a head edit", () => {
it("leaves a scene's input signature alone for a body edit, not for a head edit", async () => {
const dir = project(files("<style>a{}</style>", "x"));
const before = compositionInputSignature(dir, "compositions/a.html", "s1");
const before = await compositionInputSignature(dir, "compositions/a.html", "s1");
rewrite(dir, "<style>a{}</style>", "moved");
expect(compositionInputSignature(dir, "compositions/a.html", "s2")).toBe(before);
expect(await compositionInputSignature(dir, "compositions/a.html", "s2")).toBe(before);
rewrite(dir, "<style>b{}</style>", "moved");
expect(compositionInputSignature(dir, "compositions/a.html", "s3")).not.toBe(before);
expect(await compositionInputSignature(dir, "compositions/a.html", "s3")).not.toBe(before);
});
it("changes the root's own input signature for a body edit", () => {
it("changes the root's own input signature for a body edit", async () => {
const dir = project(files("<style>a{}</style>", "x"));
const before = compositionInputSignature(dir, "index.html", "s1");
const before = await compositionInputSignature(dir, "index.html", "s1");
rewrite(dir, "<style>a{}</style>", "moved");
expect(compositionInputSignature(dir, "index.html", "s2")).not.toBe(before);
expect(await compositionInputSignature(dir, "index.html", "s2")).not.toBe(before);
});
});
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { parseHTML } from "linkedom";
import { createProjectSignature } from "./projectSignature.js";
import { createProjectSignatureAsync } from "./projectSignature.js";
import { rootHeadContent } from "./subComposition.js";
import { resolveWithinProject } from "./safePath.js";
import { descendants } from "./compositionInsertion.js";
@@ -99,11 +99,11 @@ function rootHashes(read: SourceReader) {
// What a thumbnail of `compPath` renders from: the project minus the compositions the root
// mounts that `compPath` does not; for a scene the root counts as its head alone.
export function compositionInputSignature(
export async function compositionInputSignature(
projectDir: string,
compPath: string,
projectSignature: string,
): string {
): Promise<string> {
const key = `${projectDir}\0${compPath}`;
const known = inputSignatures.get(key);
if (known?.projectSignature === projectSignature) return known.inputSignature;
@@ -115,11 +115,12 @@ export function compositionInputSignature(
);
let inputSignature: string;
if (comp === ROOT_COMPOSITION) {
inputSignature = createProjectSignature(projectDir, excluded);
inputSignature = await createProjectSignatureAsync(projectDir, excluded);
} else {
const hashes = rootHashes(read);
if (!rootMemos.has(projectDir)) rootMemos.set(projectDir, { ...hashes, affected: null });
inputSignature = `${createProjectSignature(projectDir, excluded.add(ROOT_COMPOSITION))}:${hashes.head}`;
const rest = await createProjectSignatureAsync(projectDir, excluded.add(ROOT_COMPOSITION));
inputSignature = `${rest}:${hashes.head}`;
}
inputSignatures.set(key, { projectSignature, inputSignature });
return inputSignature;
@@ -260,8 +260,13 @@ export function listProjectFiles(
* provides one (the CLI invalidates its cache from the file watcher), falling
* back to a direct computation.
*/
export function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
export async function resolveProjectSignature(
adapter: StudioApiAdapter,
projectDir: string,
): Promise<string> {
return (
(await adapter.getProjectSignature?.(projectDir)) ?? createProjectSignatureAsync(projectDir)
);
}
/** The shared route opening: resolve the project (null → caller 404s) with its signature. */
@@ -271,7 +276,7 @@ export async function resolveProjectAndSignature(
): Promise<{ project: ResolvedProject; signature: string } | null> {
const project = await adapter.resolveProject(projectId);
if (!project) return null;
return { project, signature: resolveProjectSignature(adapter, project.dir) };
return { project, signature: await resolveProjectSignature(adapter, project.dir) };
}
interface SignaturePlan {
+7 -5
View File
@@ -56,9 +56,8 @@ import {
isGsapCoreUrl,
} from "../helpers/gsapCoreScript.js";
import {
createProjectSignature,
createProjectSignatureAsync,
resolveProjectAndSignature,
resolveProjectSignature,
} from "../helpers/projectSignature.js";
import {
createStudioMotionRenderBodyScript,
@@ -369,6 +368,7 @@ kinds.push({name:known[name]===1?name:"other",origin:!path?"unknown":path===runt
function injectStudioPreviewAugmentations(
html: string,
adapter: StudioApiAdapter,
signature: string,
projectDir: string,
activeCompositionPath: string,
options: { capture: boolean; gsapBaseUrl: string },
@@ -388,7 +388,7 @@ function injectStudioPreviewAugmentations(
marked,
`<meta name="${GSAP_BASE_URL_META}" content="${escapeGsapUrl(options.gsapBaseUrl)}">`,
),
resolveProjectSignature(adapter, projectDir),
signature,
),
options.gsapBaseUrl,
);
@@ -536,6 +536,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
bundled = injectStudioPreviewAugmentations(
ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
adapter,
signature,
project.dir,
mainCompositionPath,
{ capture, gsapBaseUrl },
@@ -549,7 +550,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
mediaCodecProbeCache,
);
bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
if (createProjectSignature(project.dir) === signature) {
if ((await createProjectSignatureAsync(project.dir)) === signature) {
rememberPreview(builtKey, bundled);
if (!capture) adapter.previewDocuments?.write(builtKey, bundled);
}
@@ -566,6 +567,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
let fallbackAugmented = injectStudioPreviewAugmentations(
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
adapter,
signature,
project.dir,
fallback.compositionPath,
{ capture, gsapBaseUrl },
@@ -686,7 +688,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
html = withRootImportMap(html, baseHref);
recordPreviewReferences(project.dir, html);
html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath));
html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, {
html = injectStudioPreviewAugmentations(html, adapter, signature, project.dir, compPath, {
capture,
gsapBaseUrl,
});
@@ -71,6 +71,29 @@ describe("GET /projects/:id/signature", () => {
expect(await response.json()).toEqual({ signature: "cached-sig" });
});
// fails without the fix: the polled signature walked every file synchronously, holding a host's event loop (a
// desktop app's main process) for the whole walk.
it("lets the event loop run while it walks a project", async () => {
const projectDir = createProjectDir();
for (let i = 0; i < 300; i++)
writeFileSync(join(projectDir, `clip-${i}.html`), COMPOSITION_HTML);
const app = new Hono();
registerProjectRoutes(app, createAdapter(projectDir));
let turns = 0;
let answered = false;
const turn = () => {
if (answered) return;
turns++;
setImmediate(turn);
};
setImmediate(turn);
const response = await app.request("http://localhost/projects/demo/signature");
answered = true;
expect(response.status).toBe(200);
expect(turns).toBeGreaterThan(0);
});
it("computes a signature that moves when project files change", async () => {
const projectDir = createProjectDir();
const app = new Hono();
@@ -43,7 +43,7 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi
api.get("/projects/:id/signature", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
return c.json({ signature: resolveProjectSignature(adapter, project.dir) });
return c.json({ signature: await resolveProjectSignature(adapter, project.dir) });
});
// Project file tree
@@ -17,7 +17,10 @@ import { createHash, randomUUID } from "node:crypto";
import type { StudioApiAdapter } from "../types.js";
import { STUDIO_MANUAL_EDITS_PATH } from "../helpers/manualEditsRenderScript.js";
import { compositionInputSignature } from "../helpers/compositionInputs.js";
import { createProjectSignature, resolveProjectAndSignature } from "../helpers/projectSignature.js";
import {
createProjectSignatureAsync,
resolveProjectAndSignature,
} from "../helpers/projectSignature.js";
import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js";
import { thumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator.js";
import { requestSubPath } from "../helpers/requestSubPath.js";
@@ -134,7 +137,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
const source = htmlFile ? readFileOnce(htmlFile) : "not-a-file";
if (source === "not-a-file") return c.json({ error: "not found" }, 404);
// Keyed on what this composition renders from, so editing one scene leaves the others cached.
const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
const inputSignature = await compositionInputSignature(project.dir, compPath, projectSignature);
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
const rawSeekTime = url.searchParams.get("t");
@@ -246,15 +249,15 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
if (!generated) return null;
const previewCopiesAtEnd = proxyActivityMark(project.dir);
const afterGeneration = await resolveProjectAndSignature(adapter, project.id);
const inputsUnchanged = (signature: string) =>
compositionInputSignature(project.dir, compPath, signature) === inputSignature;
const inputsUnchanged = async (signature: string) =>
(await compositionInputSignature(project.dir, compPath, signature)) === inputSignature;
// Both the adapter's signature and a fresh one: the adapter's can lag the watcher.
if (
previewCopiesAtStart === null ||
previewCopiesAtEnd !== previewCopiesAtStart ||
afterGeneration?.project.dir !== project.dir ||
!inputsUnchanged(afterGeneration.signature) ||
!inputsUnchanged(createProjectSignature(project.dir))
!(await inputsUnchanged(afterGeneration.signature)) ||
!(await inputsUnchanged(await createProjectSignatureAsync(project.dir)))
) {
// The browser may have rendered content written after this request
// captured its cache identity. Return the pixels to this caller,
+1 -1
View File
@@ -122,7 +122,7 @@ export interface StudioApiAdapter {
): Promise<string | null>;
/** Optional: a cached `createProjectSignature(dir)`; preview caching checks builds against it. */
getProjectSignature?: (projectDir: string) => string;
getProjectSignature?: (projectDir: string) => string | Promise<string>;
invalidateProjectSignature?: (projectDir: string) => void;
loadSharp?: () => Promise<typeof import("sharp").default>;