fix(studio): activate a composition at any path, not just compositions/

The Comps panel sets activeCompositionPath to the selected file, but
useCompositionStack's effect only pushed a stack level when that path
started with compositions/. A project laying its comps out anywhere
else, for example a generated multi-part build with parts/part-1.html
next to the root index.html, matched no branch at all: the row
highlighted and the URL hash updated while the stack silently kept the
master mounted, so the canvas and timeline stayed on index.html and any
edit landed in the root file instead of the part.

Replaced the prefix test with a plain truthiness check, so the root
stays on the master level and every other path pushes its own level.
Label derivation is unchanged, matching CompositionsTab's own
convention.
This commit is contained in:
rajanpanth
2026-08-29 20:50:16 +05:45
parent af1cb1c10d
commit adf9b0ccee
2 changed files with 77 additions and 2 deletions
@@ -42,3 +42,72 @@ describe("useCompositionStack — project scoping", () => {
});
}
});
describe("useCompositionStack — activating a composition by path", () => {
afterEach(() => {
document.body.innerHTML = "";
});
function mountStack(activeCompositionPath: string | null) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const seen: { stack: ReturnType<typeof useCompositionStack>["compositionStack"] } = {
stack: [],
};
function Harness(props: { activeCompositionPath: string | null }) {
seen.stack = useCompositionStack({ projectId: "p", ...props }).compositionStack;
return null;
}
return { root, seen, Harness, activeCompositionPath };
}
// The root stays on the master level; everything else pushes a second level.
for (const path of [
"compositions/scene-a.html",
"parts/part-1.html",
"chapter-2.html",
"a/b/c/deep.html",
]) {
it(`pushes a level for ${path}`, async () => {
const { root, seen, Harness } = mountStack(path);
await act(async () => {
root.render(<Harness activeCompositionPath={path} />);
});
expect(seen.stack).toHaveLength(2);
expect(seen.stack[1]?.id).toBe(path);
expect(seen.stack[1]?.previewUrl).toBe(`/api/projects/p/preview/comp/${path}`);
act(() => root.unmount());
});
}
it("labels a non-compositions/ path without mangling it", async () => {
const { root, seen, Harness } = mountStack("parts/part-1.html");
await act(async () => {
root.render(<Harness activeCompositionPath="parts/part-1.html" />);
});
expect(seen.stack[1]?.label).toBe("parts/part-1");
act(() => root.unmount());
});
it("keeps the master alone for the root composition", async () => {
const { root, seen, Harness } = mountStack("index.html");
await act(async () => {
root.render(<Harness activeCompositionPath="index.html" />);
});
expect(seen.stack).toHaveLength(1);
expect(seen.stack[0]?.id).toBe("master");
act(() => root.unmount());
});
});
@@ -108,7 +108,13 @@ export function useCompositionStack({
if (activeCompositionPath === "index.html") {
usePlayerStore.getState().setElements([]);
updateCompositionStack([master]);
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
} else if (activeCompositionPath) {
// Any composition file that isn't the root, wherever it lives. Gating
// this on a `compositions/` prefix meant a project laying its comps out
// anywhere else (`parts/part-1.html`, generated multi-part builds) hit
// no branch at all: the stack kept the master mounted while the Comps
// panel highlighted the row, so the canvas and timeline stayed on
// index.html and edits landed in the root file.
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(activeCompositionPath)}`;
usePlayerStore.getState().setElements([]);
@@ -116,7 +122,7 @@ export function useCompositionStack({
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
return [master, { id: activeCompositionPath, label, previewUrl }];
});
} else if (!activeCompositionPath) {
} else {
usePlayerStore.getState().setElements([]);
updateCompositionStack([master]);
}