feat(studio): open a project's root composition automatically on load (#4057)

* feat(studio): open a project's root composition automatically on load

Opening any project landed on an empty canvas ("Drop media here") with
Play disabled, even when a root index.html composition existed, because
nothing auto-selected it. Studio now opens the root composition (or the
only one, if there's just one) as soon as the file tree loads and no
URL already named a selection, and marks it "Root" in the comps list.

The root path is resolved once, in LeftSidebar from the shared file
tree, and passed down as a prop rather than re-derived independently
in the comps list from a different, driftable source array.

The settle guard is keyed to projectId, not a bare boolean, so
switching projects in-session (the studio's hash-based project switch,
same component instance) still gets its own auto-open instead of
silently inheriting the previous project's settled state.

* fix(studio): reset selection state on an in-session project switch

Switching projects in-session left activeCompPath from the old project
in place, which read as "already open" and made the new project's
auto-open settle without ever firing. useResetSelectionOnProjectSwitch
clears both activeCompPath and activeCompPathHydrated when projectId
changes, composed inside useAutoOpenRootComposition so the same commit
sees the reset before auto-open's own effect runs.

Also extracts the composition-selection wiring (state, URL hydration,
auto-open, this reset) out of App.tsx into one hook,
useActiveComposition, so App.tsx loses more lines than this fix adds
and stays under the 600-line cap without trimming unrelated lines.

* refactor(studio): un-nest the project-switch reset from auto-open

An independent review of 0bd281127 found useAutoOpenRootComposition had
grown three pass-through params purely to forward into a nested
useResetSelectionOnProjectSwitch call; none were read by its own
auto-open decision. React's render-phase state adjustment gives the
same commit-timing guarantee for a sibling call, so this calls the
reset hook directly from useActiveComposition instead, restoring
useAutoOpenRootComposition to its original 5-param signature.

Also applies two smaller review findings: a shared isHydratedFromUrlState
helper replaces three copies of the same URL-state check, and
useActiveComposition's setEditingFile/showToast params use the
existing EditingFile/AppToast types instead of restating their shape.

* fix(studio): make the file tree's project-switch staleness impossible

An independent review of ca3776e14 traced a live regression: on an
in-session project switch A->B where B's URL carries no comp param,
the reset added in this stack lets useAutoOpenRootComposition's effect
fire in the same commit-flush, but masterCompPath (derived from
fileManager.fileTree) still held project A's data for at least one
render + effect cycle, because useFileTree only cleared fileTree/
fileTreeLoaded inside a useEffect keyed on projectId, itself timed one
cycle behind. Auto-open would call onSelectComposition with A's master
path for project B and permanently mark itself settled, so B's real
root composition never opens for the rest of the session when the two
projects don't share an entry filename.

Root cause fixed in useFileTree.ts: the fetched tree is now held
together with the projectId it was fetched for, and fileTree/
fileTreeLoaded/compositions/projectDir are derived by comparing that
stored id against the live projectId, returning empty/not-loaded
whenever they don't match. This makes reading another project's data
structurally impossible, instead of adding a second reset timed to
race the first. The now-unreachable raw setFileTree setter is removed
(zero call sites repo-wide).

Every other consumer of these four fields was exposed to the same
stale window and is fixed by the same change: App.tsx's URL-hydration
guard and useDomEditSession's fileTree/projectDir, StudioLeftSidebar's
composition/asset lists, StudioRightPanel's assets/fontAssets/
projectDir, and VariablesPanel's fileTree-derived fallback comp path.

Regression test added (useResetSelectionOnProjectSwitch.test.tsx):
real useFileTree + useActiveComposition wired together with a mocked
fetch for two projects with different root files. Verified it fails
on the pre-fix useFileTree.ts (asserts B's root, gets A's stale one)
and passes on the fix.

* refactor(studio): stop the file-tree catch handler re-zeroing state

The effect already resets fileTree/compositionPaths/projectDir to
empty synchronously before the fetch starts, so the catch branch only
needs to flip loaded:true; re-writing the same empty arrays clobbered
any concurrent refreshFileTree() result for the same projectId that
landed first. Found by a simplify-pass review of 9555e920f.

* fix(studio): resolve the auto-opened composition from the filtered list

resolveMasterCompositionPath scanned the raw file list, which can include
non-composition .html (a vendored preset, a file with no
data-composition-id). Auto-open now reads the server's filtered
compositions list instead, and settles once per project either way, so a
stray .html earlier in the list can no longer become the opened target.

* fix(studio): close the same master-composition mismatch at its two sibling call sites

resolveMasterCompositionPath had two more callers still reading the raw
file tree: the sidebar's Root badge and the Variables/Slideshow panel's
master-view target. Both already had the server's filtered composition
list in scope; switch them to it too, and reword the docstring so a future
caller reaches for the right list.

* fix(studio): refresh the composition list along with the file list

refreshFileTree only patched the raw file list, leaving compositions
frozen at the project's initial load. Two callers now read compositions
for their root-composition target, so a file operation that adds or
removes a composition (create, duplicate, block install, background
removal) would leave the sidebar's Root badge and the Variables/Slideshow
panel's master target stale until the next full project switch.

* fix(studio): fix refreshFileTree ordering race and stale-field wipe

Two overlapping refreshFileTree calls for the same project could apply
out of order, and a response missing the compositions field could wipe
a known-good list. Adds a request-id guard and a stale-only fallback,
plus regression coverage for both and for LeftSidebar's root badge
sourcing from the filtered compositions list.

* refactor(studio): cancel superseded refreshFileTree requests

Simplify pass on the previous commit: reuses the id+abort pattern
already established in useFileManager.ts's openSourceForSelection
instead of only discarding the stale response after it completes.

* test(studio): assert the superseded refresh's fetch actually aborts

Closes the coverage gap the independent review flagged: the abort
call had no test asserting on it, so a dropped abort() would have
stayed green. Also cuts two comment blocks over the repo's 4-line cap.

* refactor(studio): extract shared test harness setup in useFileTree.test.tsx

Simplify pass on d069763c6: the new abort-assertion test repeated the
render setup already duplicated three times in this file. Extracts
renderHarness() to match the pattern every sibling hook test file
already uses. Also restores the caller-facing consequence dropped
from useAutoOpenRootComposition's comment trim (skipping the reset
now explicitly reads as "this hook no-ops").

* fix(studio): keep the restored comment clause under the 4-line cap

The prior commit's restored "why" clause pushed this JSDoc block to 5
lines. Folds it back to 2 content lines, keeping both the reset
requirement and its consequence.
This commit is contained in:
Miguel Ángel
2026-09-18 08:44:05 -04:00
committed by GitHub
parent 2a559714ee
commit b0e4316a20
17 changed files with 965 additions and 71 deletions
+16 -26
View File
@@ -36,7 +36,6 @@ import { useFrameCapture } from "./hooks/useFrameCapture";
import { useLintModal } from "./hooks/useLintModal";
import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
import { useToast } from "./hooks/useToast";
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
import { useStudioUrlState } from "./hooks/useStudioUrlState";
import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
import {
@@ -63,7 +62,7 @@ import { useServerConnection } from "./hooks/useServerConnection";
import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
import { readStudioUrlStateFromWindow, resolveMasterCompositionPath } from "./utils/studioUrlState";
import { useHydrateActiveCompPathFromUrl } from "./hooks/useHydrateActiveCompPathFromUrl";
import { useActiveComposition } from "./hooks/useActiveComposition";
const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
// fallow-ignore-next-line complexity
export function StudioApp() {
@@ -71,10 +70,6 @@ export function StudioApp() {
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const viewModeValue = useViewModeState();
useStudioSessionStart(projectId, resolving, waitingForServer);
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
() => initialUrlStateRef.current.activeCompPath == null,
);
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
const [compositionLoading, setCompositionLoading] = useState(true);
@@ -82,10 +77,7 @@ export function StudioApp() {
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
const activeCompPathRef = useRef(activeCompPath);
activeCompPathRef.current = activeCompPath;
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
const renderQueue = useRenderQueue(projectId, activeCompPathRef);
const captionEditMode = useCaptionStore((s) => s.isEditMode);
const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
const captionSync = useCaptionSync(projectId);
@@ -115,22 +107,27 @@ export function StudioApp() {
setRefreshKey,
});
const masterCompPath = useMemo(
() => resolveMasterCompositionPath(fileManager.fileTree),
[fileManager.fileTree],
() => resolveMasterCompositionPath(fileManager.compositions),
[fileManager.compositions],
);
const { activeCompPath, activeCompPathHydrated, setActiveCompPath, handleSelectComposition } =
useActiveComposition({
projectId,
initialUrlStateRef,
fileTree: fileManager.fileTree,
fileTreeLoaded: fileManager.fileTreeLoaded,
masterCompPath,
setEditingFile: fileManager.setEditingFile,
showToast,
});
const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
projectId,
activeCompPath,
masterCompPath,
);
useHydrateActiveCompPathFromUrl({
hydrated: activeCompPathHydrated,
fileTreeLoaded: fileManager.fileTreeLoaded,
fileTree: fileManager.fileTree,
initialUrlStateRef,
setActiveCompPath,
setHydrated: setActiveCompPathHydrated,
});
const activeCompPathRef = useRef(activeCompPath);
activeCompPathRef.current = activeCompPath;
const renderQueue = useRenderQueue(projectId, activeCompPathRef);
const previewPersistence = usePreviewPersistence({
showToast,
readOptionalProjectFile: fileManager.readOptionalProjectFile,
@@ -372,13 +369,6 @@ export function StudioApp() {
},
[appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
);
const { setEditingFile } = fileManager;
const handleSelectComposition = useCompositionContentLoader({
projectId,
setEditingFile,
setActiveCompPath,
showToast,
});
const {
designPanelActive,
inspectorPanelActive,
@@ -256,15 +256,12 @@ export const VariablesPanel = memo(function VariablesPanel({
}: VariablesPanelProps) {
const { activeCompPath, showToast } = useStudioShellContext();
const { refreshKey } = useStudioPlaybackContext();
const { readProjectFile, writeProjectFile, fileTree } = useFileManagerContext();
const { readProjectFile, writeProjectFile, fileTree, compositions } = useFileManagerContext();
const { domEditSelection } = useDomEditContext();
// On the master view (no activeCompPath) the panel targets the project's real
// main composition — the first .html in the tree — not a hardcoded index.html
// that may not exist. This same path is used for the persist write target (so
// an edit never lands in a phantom index.html) AND the handoff render command.
// Null only when the project has no composition yet, in which case sdkSession
// is also null and the panel is inert.
const effectiveCompPath = activeCompPath ?? resolveMasterCompositionPath(fileTree);
// Master view (no activeCompPath) targets the real main composition, not a
// hardcoded index.html — used for both the persist write target and the
// handoff render command. Null only when the project has no composition.
const effectiveCompPath = activeCompPath ?? resolveMasterCompositionPath(compositions);
const previewValues = usePreviewVariablesStore((s) => s.values);
const setPreviewValues = usePreviewVariablesStore((s) => s.setValues);
@@ -0,0 +1,59 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../../player/store/playerStore";
import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
import { CompositionsTab } from "./CompositionsTab";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
(
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
).happyDOM.settings.disableIframePageLoading = true;
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
usePlayerStore.setState({ thumbnailContentRevision: 0 });
});
function mount(compositions: string[], masterCompositionPath: string | null) {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(
<CompositionsTab
projectId="demo"
compositions={compositions}
activeComposition={null}
masterCompositionPath={masterCompositionPath}
onSelect={vi.fn()}
/>,
);
});
return host;
}
describe("CompositionsTab root badge", () => {
it("marks the composition matching masterCompositionPath as root", () => {
const compositions = ["index.html", "compositions/headline.html"];
const host = mount(compositions, resolveMasterCompositionPath(compositions));
const cards = host.querySelectorAll<HTMLElement>('[draggable="true"]');
expect(cards).toHaveLength(2);
expect(cards[0]?.textContent).toContain("Root");
expect(cards[1]?.textContent).not.toContain("Root");
});
it("marks nothing as root when masterCompositionPath is null", () => {
const compositions = ["compositions/hero.html", "compositions/outro.html"];
const host = mount(compositions, null);
const cards = host.querySelectorAll<HTMLElement>('[draggable="true"]');
expect(cards[0]?.textContent).not.toContain("Root");
expect(cards[1]?.textContent).not.toContain("Root");
});
});
@@ -10,6 +10,8 @@ interface CompositionsTabProps {
projectId: string;
compositions: string[];
activeComposition: string | null;
/** The project's root composition (same value App.tsx auto-opens on load), or null if none. */
masterCompositionPath?: string | null;
onSelect: (comp: string) => void;
onRenderComposition?: (comp: string) => void;
onAddToTimeline?: (comp: string) => void;
@@ -117,6 +119,7 @@ function CompCard({
projectId,
comp,
isActive,
isRoot,
onSelect,
onRender,
isRendering,
@@ -127,6 +130,7 @@ function CompCard({
projectId: string;
comp: string;
isActive: boolean;
isRoot: boolean;
onSelect: () => void;
onRender?: () => void;
isRendering?: boolean;
@@ -298,6 +302,15 @@ function CompCard({
>
<div className="flex items-center gap-1">
<span className="text-[11px] font-medium text-neutral-300 truncate">{name}</span>
{isRoot && (
<span
aria-label="Root composition — opens automatically on load"
title="Root composition — opens automatically on load"
className="flex-shrink-0 rounded-full bg-neutral-700/60 px-1.5 py-px text-[8px] font-bold uppercase tracking-wide text-neutral-300"
>
Root
</span>
)}
{lintInfo && lintInfo.count > 0 && (
<span
aria-label={`${lintInfo.count} lint finding${lintInfo.count === 1 ? "" : "s"}`}
@@ -368,6 +381,7 @@ export const CompositionsTab = memo(function CompositionsTab({
projectId,
compositions,
activeComposition,
masterCompositionPath = null,
onSelect,
onRenderComposition,
onAddToTimeline,
@@ -391,6 +405,7 @@ export const CompositionsTab = memo(function CompositionsTab({
projectId={projectId}
comp={comp}
isActive={activeComposition === comp}
isRoot={comp === masterCompositionPath}
onSelect={() => onSelect(comp)}
onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
@@ -0,0 +1,47 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { usePlayerStore } from "../../player/store/playerStore";
import { LeftSidebar } from "./LeftSidebar";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
(
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
).happyDOM.settings.disableIframePageLoading = true;
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
usePlayerStore.setState({ thumbnailContentRevision: 0 });
});
describe("LeftSidebar root badge source", () => {
it("derives the root badge from the filtered compositions list, not the raw file tree", () => {
// `fileTree` includes a non-composition index.html (a vendored preset) that the
// server's filtered `compositions` list excludes — the badge must source from
// `compositions`, not the raw file tree.
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(
<LeftSidebar
projectId="demo"
compositions={["hero.html"]}
assets={[]}
fileTree={["index.html", "hero.html"]}
activeComposition={null}
onSelectComposition={vi.fn()}
/>,
);
});
const cards = host.querySelectorAll<HTMLElement>('[draggable="true"]');
expect(cards).toHaveLength(1);
expect(cards[0]?.textContent).toContain("Root");
});
});
@@ -3,6 +3,7 @@ import {
useState,
useCallback,
useImperativeHandle,
useMemo,
useRef,
forwardRef,
type ReactNode,
@@ -11,6 +12,7 @@ import { CompositionsTab } from "./CompositionsTab";
import { AssetsTab } from "./AssetsTab";
import { trackStudioEvent } from "../../utils/studioTelemetry";
import { safeLocalStorage } from "../../utils/safeStorage";
import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
import { FileTree } from "../editor/FileTree";
import { Tooltip } from "../ui";
@@ -120,6 +122,10 @@ export const LeftSidebar = memo(
const tabRef = useRef(tab);
tabRef.current = tab;
const tablistRef = useRef<HTMLDivElement>(null);
const masterCompositionPath = useMemo(
() => resolveMasterCompositionPath(compositions),
[compositions],
);
const selectTab = useCallback((t: SidebarTab) => {
setTab(t);
@@ -228,6 +234,7 @@ export const LeftSidebar = memo(
projectId={projectId}
compositions={compositions}
activeComposition={activeComposition}
masterCompositionPath={masterCompositionPath}
onSelect={onSelectComposition}
onAddToTimeline={onAddCompositionToTimeline}
onRenderComposition={onRenderComposition}
@@ -24,7 +24,6 @@ export function FileManagerProvider({
projectDir,
fileTree,
fileTreeLoaded,
setFileTree,
editingPathRef,
projectIdRef,
saveRafRef,
@@ -68,7 +67,6 @@ export function FileManagerProvider({
projectDir,
fileTree,
fileTreeLoaded,
setFileTree,
editingPathRef,
projectIdRef,
saveRafRef,
@@ -106,7 +104,6 @@ export function FileManagerProvider({
projectDir,
fileTree,
fileTreeLoaded,
setFileTree,
editingPathRef,
projectIdRef,
saveRafRef,
@@ -0,0 +1,69 @@
import { useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { useHydrateActiveCompPathFromUrl } from "./useHydrateActiveCompPathFromUrl";
import { useAutoOpenRootComposition } from "./useAutoOpenRootComposition";
import { useCompositionContentLoader } from "./useCompositionContentLoader";
import { useResetSelectionOnProjectSwitch } from "./useResetSelectionOnProjectSwitch";
import { isHydratedFromUrlState, type StudioUrlState } from "../utils/studioUrlState";
import type { AppToast, EditingFile } from "../utils/studioHelpers";
/** Owns which composition is open: state, URL hydration, auto-open and content loading. */
export function useActiveComposition({
projectId,
initialUrlStateRef,
fileTree,
fileTreeLoaded,
masterCompPath,
setEditingFile,
showToast,
}: {
projectId: string | null;
initialUrlStateRef: MutableRefObject<StudioUrlState>;
fileTree: string[];
fileTreeLoaded: boolean;
masterCompPath: string | null;
setEditingFile: (file: EditingFile) => void;
showToast: (message: string, tone?: AppToast["tone"]) => void;
}): {
activeCompPath: string | null;
activeCompPathHydrated: boolean;
setActiveCompPath: Dispatch<SetStateAction<string | null>>;
handleSelectComposition: (comp: string) => void;
} {
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(() =>
isHydratedFromUrlState(initialUrlStateRef.current),
);
useResetSelectionOnProjectSwitch({
projectId,
initialUrlStateRef,
setActiveCompPath,
setActiveCompPathHydrated,
});
useHydrateActiveCompPathFromUrl({
hydrated: activeCompPathHydrated,
fileTreeLoaded,
fileTree,
initialUrlStateRef,
setActiveCompPath,
setHydrated: setActiveCompPathHydrated,
});
const handleSelectComposition = useCompositionContentLoader({
projectId,
setEditingFile,
setActiveCompPath,
showToast,
});
useAutoOpenRootComposition({
projectId,
activeCompPath,
activeCompPathHydrated,
masterCompPath,
onSelectComposition: handleSelectComposition,
});
return { activeCompPath, activeCompPathHydrated, setActiveCompPath, handleSelectComposition };
}
@@ -0,0 +1,151 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoOpenRootComposition } from "./useAutoOpenRootComposition";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type Props = {
projectId: string | null;
activeCompPath: string | null;
activeCompPathHydrated: boolean;
masterCompPath: string | null;
onSelectComposition: (comp: string) => void;
};
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
});
function Harness(props: Props) {
useAutoOpenRootComposition(props);
return null;
}
function renderHarness(props: Props) {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
let current = props;
act(() => {
root?.render(<Harness {...current} />);
});
return {
rerender: (next: Partial<Props>) => {
current = { ...current, ...next };
act(() => {
root?.render(<Harness {...current} />);
});
},
};
}
describe("useAutoOpenRootComposition", () => {
it("opens the master composition once hydration settles on no selection", () => {
const onSelectComposition = vi.fn();
renderHarness({
projectId: "demo",
activeCompPath: null,
activeCompPathHydrated: true,
masterCompPath: "index.html",
onSelectComposition,
});
expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
});
it("waits for the file tree before opening anything", () => {
const onSelectComposition = vi.fn();
const { rerender } = renderHarness({
projectId: "demo",
activeCompPath: null,
activeCompPathHydrated: true,
masterCompPath: null,
onSelectComposition,
});
expect(onSelectComposition).not.toHaveBeenCalled();
rerender({ masterCompPath: "index.html" });
expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
});
it("never auto-opens once a URL-hydrated selection is already active", () => {
const onSelectComposition = vi.fn();
renderHarness({
projectId: "demo",
activeCompPath: "hero.html",
activeCompPathHydrated: true,
masterCompPath: "index.html",
onSelectComposition,
});
expect(onSelectComposition).not.toHaveBeenCalled();
});
it("does not auto-open before hydration has settled", () => {
const onSelectComposition = vi.fn();
const { rerender } = renderHarness({
projectId: "demo",
activeCompPath: null,
activeCompPathHydrated: false,
masterCompPath: "index.html",
onSelectComposition,
});
expect(onSelectComposition).not.toHaveBeenCalled();
rerender({ activeCompPathHydrated: true });
expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
});
it("fires only once even if the selection is cleared back to null later", () => {
const onSelectComposition = vi.fn();
const { rerender } = renderHarness({
projectId: "demo",
activeCompPath: null,
activeCompPathHydrated: true,
masterCompPath: "index.html",
onSelectComposition,
});
expect(onSelectComposition).toHaveBeenCalledTimes(1);
rerender({ activeCompPath: "index.html" });
rerender({ activeCompPath: null });
expect(onSelectComposition).toHaveBeenCalledTimes(1);
});
it("auto-opens again for a different project after already settling for the first one", () => {
const onSelectComposition = vi.fn();
const { rerender } = renderHarness({
projectId: "project-a",
activeCompPath: null,
activeCompPathHydrated: true,
masterCompPath: "index.html",
onSelectComposition,
});
expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
// This hook's OWN decision logic, given activeCompPath/masterCompPath already reset for
// the new project (by whatever reset the caller wires up — see
// useResetSelectionOnProjectSwitch.test.tsx for that end-to-end case with real state).
rerender({
projectId: "project-b",
activeCompPath: null,
masterCompPath: null,
});
expect(onSelectComposition).toHaveBeenCalledTimes(1);
rerender({ masterCompPath: "hero.html" });
expect(onSelectComposition).toHaveBeenCalledTimes(2);
expect(onSelectComposition).toHaveBeenLastCalledWith("hero.html");
});
});
@@ -0,0 +1,33 @@
import { useEffect, useRef } from "react";
/**
* Opens the root composition once hydration settles with no selection, at most once per `projectId`.
* Caller must call `useResetSelectionOnProjectSwitch` on a project switch, or a stale selection reads as open.
*/
export function useAutoOpenRootComposition({
projectId,
activeCompPath,
activeCompPathHydrated,
masterCompPath,
onSelectComposition,
}: {
projectId: string | null;
activeCompPath: string | null;
activeCompPathHydrated: boolean;
masterCompPath: string | null;
onSelectComposition: (comp: string) => void;
}): void {
const settledForProjectRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (settledForProjectRef.current === projectId) return;
if (!activeCompPathHydrated) return;
if (activeCompPath !== null) {
settledForProjectRef.current = projectId; // a URL-hydrated selection is already open
return;
}
if (!masterCompPath) return; // file tree not loaded yet, or no composition at all
settledForProjectRef.current = projectId;
onSelectComposition(masterCompPath);
}, [projectId, activeCompPath, activeCompPathHydrated, masterCompPath, onSelectComposition]);
}
@@ -7,7 +7,6 @@ vi.mock("./useFileTree", () => ({
useFileTree: () => ({
projectDir: "",
fileTree: [],
setFileTree: vi.fn(),
fileTreeLoaded: true,
refreshFileTree: vi.fn(async () => {}),
compositions: [],
@@ -67,7 +67,6 @@ export function useFileManager({
const {
projectDir,
fileTree,
setFileTree,
fileTreeLoaded,
refreshFileTree,
compositions,
@@ -501,7 +500,6 @@ export function useFileManager({
projectDir,
fileTree,
fileTreeLoaded,
setFileTree,
// Refs
editingPathRef,
@@ -0,0 +1,214 @@
// @vitest-environment happy-dom
import { act, useImperativeHandle, useRef, forwardRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useFileTree } from "./useFileTree";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
});
interface Handle {
compositions: string[];
refresh: () => Promise<void>;
}
const Harness = forwardRef<Handle, { projectId: string }>(function Harness({ projectId }, ref) {
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
const { compositions, refreshFileTree } = useFileTree({ projectId, projectIdRef });
useImperativeHandle(ref, () => ({ compositions, refresh: refreshFileTree }), [
compositions,
refreshFileTree,
]);
return null;
});
async function renderHarness(projectId: string): Promise<{ current: Handle | null }> {
const handleRef = { current: null as Handle | null };
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () => {
root?.render(
<Harness
ref={(h) => {
handleRef.current = h;
}}
projectId={projectId}
/>,
);
await Promise.resolve();
await Promise.resolve();
});
return handleRef;
}
describe("useFileTree.refreshFileTree", () => {
it("updates compositions, not just the raw file list, on refresh", async () => {
let call = 0;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
call += 1;
// Initial load: one composition. After a refresh (e.g. a new file created),
// the server now reports a second one — this is the exact shape a refresh
// after creating/duplicating a composition produces.
const body =
call === 1
? { files: ["index.html"], compositions: ["index.html"] }
: { files: ["index.html", "hero.html"], compositions: ["index.html", "hero.html"] };
return new Response(JSON.stringify(body), { status: 200 });
});
const handleRef = await renderHarness("project-a");
expect(handleRef.current?.compositions).toEqual(["index.html"]);
await act(async () => {
await handleRef.current?.refresh();
});
expect(handleRef.current?.compositions).toEqual(["index.html", "hero.html"]);
fetchSpy.mockRestore();
});
it("keeps the most-recently-issued refresh's data when an earlier one resolves later", async () => {
let call = 0;
const deferred: Array<(value: Response) => void> = [];
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
call += 1;
if (call === 1) {
return new Response(
JSON.stringify({ files: ["index.html"], compositions: ["index.html"] }),
{
status: 200,
},
);
}
return new Promise<Response>((resolve) => {
deferred.push(resolve);
});
});
const handleRef = await renderHarness("project-a");
let firstDone = false;
let secondDone = false;
act(() => {
void handleRef.current?.refresh().then(() => {
firstDone = true;
});
void handleRef.current?.refresh().then(() => {
secondDone = true;
});
});
// The second (later-issued) request resolves first, with its own data.
await act(async () => {
deferred[1](
new Response(
JSON.stringify({
files: ["index.html", "second.html"],
compositions: ["index.html", "second.html"],
}),
{
status: 200,
},
),
);
await vi.waitFor(() => secondDone);
});
expect(handleRef.current?.compositions).toEqual(["index.html", "second.html"]);
// The first (earlier-issued, now stale) request resolves after — it must not win.
await act(async () => {
deferred[0](
new Response(
JSON.stringify({
files: ["index.html", "stale.html"],
compositions: ["index.html", "stale.html"],
}),
{
status: 200,
},
),
);
await vi.waitFor(() => firstDone);
});
expect(handleRef.current?.compositions).toEqual(["index.html", "second.html"]);
fetchSpy.mockRestore();
});
it("aborts the superseded request's fetch when a newer refresh is issued", async () => {
let call = 0;
const signals: AbortSignal[] = [];
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(async (_url, init?: RequestInit) => {
call += 1;
if (call === 1) {
return new Response(
JSON.stringify({ files: ["index.html"], compositions: ["index.html"] }),
{ status: 200 },
);
}
if (init?.signal) signals.push(init.signal);
return new Promise<Response>(() => {});
});
const handleRef = await renderHarness("project-a");
act(() => {
void handleRef.current?.refresh();
});
await act(async () => {
await Promise.resolve();
});
expect(signals).toHaveLength(1);
expect(signals[0].aborted).toBe(false);
act(() => {
void handleRef.current?.refresh();
});
await act(async () => {
await Promise.resolve();
});
expect(signals).toHaveLength(2);
expect(signals[0].aborted).toBe(true);
expect(signals[1].aborted).toBe(false);
fetchSpy.mockRestore();
});
it("keeps the prior compositions list when a refresh response omits the field", async () => {
let call = 0;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => {
call += 1;
const body =
call === 1
? { files: ["index.html"], compositions: ["index.html"] }
: { files: ["index.html", "hero.html"] };
return new Response(JSON.stringify(body), { status: 200 });
});
const handleRef = await renderHarness("project-a");
expect(handleRef.current?.compositions).toEqual(["index.html"]);
await act(async () => {
await handleRef.current?.refresh();
});
// `files` grew but the response carried no `compositions` field — the known-good
// list must survive, not collapse to empty.
expect(handleRef.current?.compositions).toEqual(["index.html"]);
fetchSpy.mockRestore();
});
});
+66 -24
View File
@@ -1,5 +1,5 @@
import { buildProjectApiPath } from "../utils/projectRouting";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { FONT_EXT } from "../utils/mediaTypes";
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import { captureProjectProvenance } from "../components/feedback/projectProvenance";
@@ -9,52 +9,95 @@ interface UseFileTreeOptions {
projectIdRef: React.RefObject<string | null>;
}
interface FetchedFileTree {
projectId: string;
loaded: boolean;
fileTree: string[];
compositionPaths: string[];
projectDir: string | null;
}
// Stable references so a mismatched projectId doesn't create a new array every render
// and defeat useMemo/useCallback deps downstream (e.g. the `assets` memo below).
const EMPTY_FILE_LIST: string[] = [];
/**
* Holds the tree with the `projectId` it was fetched for; exposes empty/not-loaded
* whenever that id doesn't match the current one, so a switch can't read another project's tree.
*/
export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
const [projectDir, setProjectDir] = useState<string | null>(null);
const [fileTree, setFileTree] = useState<string[]>([]);
const [compositionPaths, setCompositionPaths] = useState<string[]>([]);
const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
const [fetched, setFetched] = useState<FetchedFileTree | null>(null);
const current = fetched?.projectId === projectId ? fetched : null;
const fileTree = current?.fileTree ?? EMPTY_FILE_LIST;
const compositionPaths = current?.compositionPaths ?? EMPTY_FILE_LIST;
const projectDir = current?.projectDir ?? null;
const fileTreeLoaded = current?.loaded ?? false;
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!projectId) {
setFileTreeLoaded(false);
return;
}
if (!projectId) return;
let cancelled = false;
setFileTreeLoaded(false);
setFetched({ projectId, loaded: false, fileTree: [], compositionPaths: [], projectDir: null });
fetch(buildProjectApiPath(projectId))
.then((r) => r.json())
.then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
if (cancelled) return;
if (data.files) setFileTree(data.files);
if (data.compositions) setCompositionPaths(data.compositions);
setProjectDir(typeof data.dir === "string" ? data.dir : null);
setFetched({
projectId,
loaded: true,
fileTree: data.files ?? [],
compositionPaths: data.compositions ?? [],
projectDir: typeof data.dir === "string" ? data.dir : null,
});
// Snapshot how this project was made, while the listing is in hand and
// the app is still alive. A crash later has no other way to learn it.
void captureProjectProvenance(projectId, data.files ?? [], data.compositions ?? []);
})
.catch(() => {
if (!cancelled) setProjectDir(null);
})
.finally(() => {
if (!cancelled) setFileTreeLoaded(true);
if (!cancelled) {
setFetched((prev) => (prev?.projectId === projectId ? { ...prev, loaded: true } : prev));
}
});
return () => {
cancelled = true;
};
}, [projectId]);
const refreshRequestRef = useRef(0);
const refreshAbortRef = useRef<AbortController | null>(null);
const refreshFileTree = useCallback(async () => {
const pid = projectIdRef.current;
if (!pid) return;
const res = await fetch(buildProjectApiPath(pid));
const data = await res.json();
if (data.files) setFileTree(data.files);
// Same id+abort pair as useFileManager.ts's openSourceForSelection: cancel the
// superseded request instead of letting it complete and discarding the result.
refreshAbortRef.current?.abort();
const requestId = ++refreshRequestRef.current;
const controller = new AbortController();
refreshAbortRef.current = controller;
let data: { files?: string[]; compositions?: string[] };
try {
const res = await fetch(buildProjectApiPath(pid), { signal: controller.signal });
data = await res.json();
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
throw err;
}
if (data.files && requestId === refreshRequestRef.current) {
setFetched((prev) =>
prev?.projectId === pid
? {
...prev,
fileTree: data.files ?? prev.fileTree,
// A response that omits `compositions` must not wipe out a
// known-good list — only replace it when the field is present.
compositionPaths: data.compositions ?? prev.compositionPaths,
}
: prev,
);
}
}, [projectIdRef]);
const compositions = compositionPaths;
const assets = useMemo(
() =>
fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
@@ -76,10 +119,9 @@ export function useFileTree({ projectId, projectIdRef }: UseFileTreeOptions) {
return {
projectDir,
fileTree,
setFileTree,
fileTreeLoaded,
refreshFileTree,
compositions,
compositions: compositionPaths,
assets,
fontAssets,
};
@@ -0,0 +1,234 @@
// @vitest-environment happy-dom
import { act, useMemo, useRef, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useResetSelectionOnProjectSwitch } from "./useResetSelectionOnProjectSwitch";
import { useAutoOpenRootComposition } from "./useAutoOpenRootComposition";
import { useActiveComposition } from "./useActiveComposition";
import { useFileTree } from "./useFileTree";
import {
isHydratedFromUrlState,
readStudioUrlStateFromWindow,
resolveMasterCompositionPath,
} from "../utils/studioUrlState";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
window.location.hash = "";
});
describe("useResetSelectionOnProjectSwitch", () => {
function ResetHarness({ projectId }: { projectId: string | null }) {
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const [activeCompPath, setActiveCompPath] = useState<string | null>("stale-comp.html");
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(true);
useResetSelectionOnProjectSwitch({
projectId,
initialUrlStateRef,
setActiveCompPath,
setActiveCompPathHydrated,
});
return (
<div
data-active-comp-path={activeCompPath ?? ""}
data-hydrated={String(activeCompPathHydrated)}
/>
);
}
it("does not reset on the initial resolution from null", () => {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(<ResetHarness projectId={null} />);
});
act(() => {
root?.render(<ResetHarness projectId="project-a" />);
});
const el = host.firstElementChild as HTMLElement;
expect(el.dataset.activeCompPath).toBe("stale-comp.html");
});
it("resets activeCompPath and re-derives hydrated on an actual project switch", () => {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(<ResetHarness projectId="project-a" />);
});
act(() => {
root?.render(<ResetHarness projectId="project-b" />);
});
const el = host.firstElementChild as HTMLElement;
expect(el.dataset.activeCompPath).toBe("");
expect(el.dataset.hydrated).toBe("true");
});
});
describe("project switch integration (mirrors App.tsx's wiring)", () => {
function AppLikeHarness({
projectId,
masterCompPath,
onSelectComposition,
}: {
projectId: string | null;
masterCompPath: string | null;
onSelectComposition: (comp: string) => void;
}) {
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(() =>
isHydratedFromUrlState(initialUrlStateRef.current),
);
// Mirrors handleSelectComposition (useCompositionContentLoader): selecting a composition
// also updates activeCompPath, which is exactly the state that must be reset on switch.
const handleSelect = (comp: string) => {
setActiveCompPath(comp);
onSelectComposition(comp);
};
// Mirrors useActiveComposition's wiring: reset as a sibling call, before auto-open.
useResetSelectionOnProjectSwitch({
projectId,
initialUrlStateRef,
setActiveCompPath,
setActiveCompPathHydrated,
});
useAutoOpenRootComposition({
projectId,
activeCompPath,
activeCompPathHydrated,
masterCompPath,
onSelectComposition: handleSelect,
});
return null;
}
it("opens the new project's root after switching projects with a composition already open", () => {
const onSelectComposition = vi.fn();
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
// Project A resolves and auto-opens its root, which also sets activeCompPath — the
// "something already open" state a real user would be in before switching projects.
act(() => {
root?.render(
<AppLikeHarness
projectId="project-a"
masterCompPath="index.html"
onSelectComposition={onSelectComposition}
/>,
);
});
expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("index.html");
onSelectComposition.mockClear();
// Switch to project B in-session (hash change, no remount) — this is the exact scenario
// the adversarial review found broken: activeCompPath was never reset, so project B's
// auto-open silently no-op'd because activeCompPath still read as "already set".
act(() => {
root?.render(
<AppLikeHarness
projectId="project-b"
masterCompPath="hero.html"
onSelectComposition={onSelectComposition}
/>,
);
});
expect(onSelectComposition).toHaveBeenCalledExactlyOnceWith("hero.html");
});
});
describe("full stack: useFileTree -> useActiveComposition (masterCompPath staleness)", () => {
const PROJECT_DATA: Record<string, { files: string[]; compositions: string[] }> = {
"project-a": { files: ["index.html"], compositions: ["index.html"] },
"project-b": { files: ["hero.html"], compositions: ["hero.html"] },
// A preset/vendor .html can be in `files` without being a real composition —
// this is the exact shape a review found auto-open mis-targeting.
"project-c": { files: ["preset.html", "hero.html"], compositions: ["hero.html"] },
};
function mockFetch(url: string): Promise<Response> {
const match = /^\/api\/projects\/([^/]+)$/.exec(url);
if (match) {
const data = PROJECT_DATA[decodeURIComponent(match[1])] ?? { files: [], compositions: [] };
return Promise.resolve(new Response(JSON.stringify(data), { status: 200 }));
}
return Promise.resolve(new Response(null, { status: 404 }));
}
function FullStackHarness({ projectId }: { projectId: string | null }) {
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
const { fileTree, fileTreeLoaded, compositions } = useFileTree({ projectId, projectIdRef });
const masterCompPath = useMemo(
() => resolveMasterCompositionPath(compositions),
[compositions],
);
const { activeCompPath } = useActiveComposition({
projectId,
initialUrlStateRef,
fileTree,
fileTreeLoaded,
masterCompPath,
setEditingFile: vi.fn(),
showToast: vi.fn(),
});
return <div data-active-comp-path={activeCompPath ?? ""} />;
}
it("opens project B's real root, not project A's stale master path, on an in-session switch", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as typeof fetch);
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () => {
root?.render(<FullStackHarness projectId="project-a" />);
await Promise.resolve();
await Promise.resolve();
});
let el = host.firstElementChild as HTMLElement;
expect(el.dataset.activeCompPath).toBe("index.html");
// Switch in-session (no remount) before asserting — this is the exact race the
// adversarial review found: project A's fileTree/masterCompPath are still in state
// for at least one render after projectId flips, until B's own fetch resolves.
await act(async () => {
root?.render(<FullStackHarness projectId="project-b" />);
await Promise.resolve();
await Promise.resolve();
});
el = host.firstElementChild as HTMLElement;
expect(el.dataset.activeCompPath).toBe("hero.html");
fetchSpy.mockRestore();
});
it("auto-opens the real composition, not a non-composition .html earlier in the file list", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(mockFetch as typeof fetch);
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () => {
root?.render(<FullStackHarness projectId="project-c" />);
await Promise.resolve();
await Promise.resolve();
});
const el = host.firstElementChild as HTMLElement;
expect(el.dataset.activeCompPath).toBe("hero.html");
fetchSpy.mockRestore();
});
});
@@ -0,0 +1,34 @@
import { useRef, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import {
isHydratedFromUrlState,
readStudioUrlStateFromWindow,
type StudioUrlState,
} from "../utils/studioUrlState";
/**
* Resets selection when `projectId` switches in-session. Runs during render, not an
* effect, so the caller never sees a stale activeCompPath in the same commit.
*/
export function useResetSelectionOnProjectSwitch({
projectId,
initialUrlStateRef,
setActiveCompPath,
setActiveCompPathHydrated,
}: {
projectId: string | null;
initialUrlStateRef: MutableRefObject<StudioUrlState>;
setActiveCompPath: Dispatch<SetStateAction<string | null>>;
setActiveCompPathHydrated: Dispatch<SetStateAction<boolean>>;
}): void {
const previousProjectIdRef = useRef(projectId);
if (previousProjectIdRef.current !== projectId) {
const isSwitch = previousProjectIdRef.current !== null;
previousProjectIdRef.current = projectId;
if (isSwitch) {
initialUrlStateRef.current = readStudioUrlStateFromWindow();
setActiveCompPath(null);
setActiveCompPathHydrated(isHydratedFromUrlState(initialUrlStateRef.current));
}
}
}
+15 -7
View File
@@ -31,16 +31,24 @@ export interface StudioUrlState {
const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"];
/**
* The composition a schema-level panel (Variables / Slideshow) targets on the
* master view, where there is no explicit `activeCompPath`. Prefer the
* `index.html` convention, but fall back to the first `.html` in the file tree
* The composition auto-open and a schema-level panel (Variables / Slideshow)
* target on the master view, where there is no explicit `activeCompPath`.
* Prefer the `index.html` convention, but fall back to the first entry
* (composition-browser order) so projects whose entry file is `card.html`,
* `hero.html`, etc. don't silently mis-target a non-existent `index.html`.
* Returns null when the project carries no composition file at all.
* Takes the server's filtered composition list, never the raw file tree —
* a non-composition `.html` (a vendored preset, one with no
* `data-composition-id`) must never become the target. Returns null when the
* project carries no composition file at all.
*/
export function resolveMasterCompositionPath(fileTree: string[]): string | null {
if (fileTree.includes("index.html")) return "index.html";
return fileTree.find((p) => p.endsWith(".html")) ?? null;
export function resolveMasterCompositionPath(compositions: string[]): string | null {
if (compositions.includes("index.html")) return "index.html";
return compositions.find((p) => p.endsWith(".html")) ?? null;
}
/** A URL with no `activeCompPath` needs no hydration step — there's nothing to apply. */
export function isHydratedFromUrlState(urlState: StudioUrlState): boolean {
return urlState.activeCompPath == null;
}
export function normalizeStudioUrlPanelTab(tab: RightPanelTab | null): RightPanelTab | null {