From adf9b0cceeb561fd5424e8cad73b781f5383a9ef Mon Sep 17 00:00:00 2001 From: rajanpanth Date: Sat, 29 Aug 2026 20:50:16 +0545 Subject: [PATCH] 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. --- .../nle/useCompositionStack.test.tsx | 69 +++++++++++++++++++ .../src/components/nle/useCompositionStack.ts | 10 ++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/nle/useCompositionStack.test.tsx b/packages/studio/src/components/nle/useCompositionStack.test.tsx index a3d300bc7..d40a5abaa 100644 --- a/packages/studio/src/components/nle/useCompositionStack.test.tsx +++ b/packages/studio/src/components/nle/useCompositionStack.test.tsx @@ -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["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(); + }); + + 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(); + }); + + 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(); + }); + + expect(seen.stack).toHaveLength(1); + expect(seen.stack[0]?.id).toBe("master"); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/nle/useCompositionStack.ts b/packages/studio/src/components/nle/useCompositionStack.ts index e21018463..feb947c42 100644 --- a/packages/studio/src/components/nle/useCompositionStack.ts +++ b/packages/studio/src/components/nle/useCompositionStack.ts @@ -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]); }