diff --git a/packages/studio/src/components/nle/useCompositionStack.test.tsx b/packages/studio/src/components/nle/useCompositionStack.test.tsx index a3d300bc79..d40a5abaa8 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 e210184637..feb947c42d 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]); }