Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions packages/studio/src/hooks/timelineAudioGroupCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,30 @@ export async function createAudioGroupAndAssignMembers({
* expanded-rows resolution as element-visibility, for the same reason — a
* nested sub-composition child has no entry in the raw store list.
*/
/**
* Run the group write; on failure log it, toast it, and rethrow it.
*
* Module scope, not the hook's: the React Compiler cannot lower a `throw` inside
* a `try`/`catch` and declines the whole hook when it finds one. The rethrow is
* the point, not an oversight — the carve's auto-group chains
* `.then(() => ({ ...next, sources: [groupId] }))` off this promise, so
* swallowing here let it persist a carve pointing at a group that was never
* written, the exact silent no-op the throw inside
* `createAudioGroupAndAssignMembers` exists to prevent.
*/
async function reportGroupFailure(
showToast: (message: string, tone?: "error" | "info") => void,
write: () => Promise<void>,
): Promise<void> {
try {
await write();
} catch (error) {
console.error("[Timeline] Failed to group voice clips", error);
showToast(error instanceof Error ? error.message : "Failed to group voice clips");
throw error;
}
}

export function useAudioGroupCarveAssignment({
projectIdRef,
activeCompPath,
Expand Down Expand Up @@ -294,7 +318,7 @@ export function useAudioGroupCarveAssignment({
const domId = runtimeAudioId(item);
return domId !== null && wanted.has(domId);
});
try {
await reportGroupFailure(showToast, async () => {
// Loud, not silent: an unresolved id used to leave `elements` short,
// `createAudioGroupAndAssignMembers` returning early with no write, and
// the carve still persisting `sources: [groupId]` for a group that was
Expand All @@ -317,17 +341,7 @@ export function useAudioGroupCarveAssignment({
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
});
} catch (error) {
console.error("[Timeline] Failed to group voice clips", error);
const message = error instanceof Error ? error.message : "Failed to group voice clips";
showToast(message);
// Rethrown, not just reported: the carve's auto-group chains
// `.then(() => ({ ...next, sources: [groupId] }))` off this promise, so
// swallowing here let it persist a carve pointing at a group that was
// never written — the exact silent no-op the throw inside
// `createAudioGroupAndAssignMembers` exists to prevent.
throw error;
}
});
},
[
activeCompPath,
Expand Down
18 changes: 10 additions & 8 deletions packages/studio/src/hooks/useAnimatedPropertyCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,8 @@ async function commitKeyframeProps(
export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
const { selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache } = deps;

// The single routing boundary for set, keyframe, whole-tween and first-group writes.
const commitAnimatedProperties = useCallback(
// This is the single routing boundary for set, keyframe, whole-tween, and first-group writes.
// fallow-ignore-next-line complexity
async (selection: DomEditSelection, props: Record<string, number | string>): Promise<void> => {
if (!gsapCommitMutation) return;
const propEntries = Object.entries(props);
Expand Down Expand Up @@ -461,10 +460,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {

// The picked anim comes from the (possibly stale) panel cache: if keyframes
// were just removed or the script changed underneath us, its id is gone
// server-side and the commit 404s. The raw commit already toasts; we catch
// so the rejection doesn't escape as an uncaught promise, and bump the cache
// so selectedGsapAnimations re-syncs and the user's next edit self-heals.
try {
// server-side and the commit 404s. The raw commit already toasts; the
// handler below bumps the cache so selectedGsapAnimations re-syncs and the
// next edit self-heals. A `.catch` and not a `try`: the React Compiler
// cannot lower a `throw` inside a `try`/`catch`, and declines the hook.
// fallow-ignore-next-line complexity
const write = async (): Promise<void> => {
// Animated element → keyframe at the playhead, EXACTLY like manual drag /
// resize / rotate: if the picked anim is still a static `set`,
// commitKeyframeProps converts it to keyframes first, then writes the new
Expand Down Expand Up @@ -580,10 +581,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
return;
}
throw new GsapEditBlockedError("no-selector");
} catch (error) {
};
await write().catch((error: unknown) => {
bumpGsapCache();
throw error;
}
});
},
[selectedGsapAnimations, gsapCommitMutation, previewIframeRef, bumpGsapCache],
);
Expand Down
42 changes: 24 additions & 18 deletions packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,25 +484,31 @@ export function useAppHotkeys({

// ── Stable callback ref (one ref replaces fifteen) ──

// Written after commit, not during render: a ref write in the hook body is a
// render side effect and the React Compiler declines the whole hook when it
// sees one. Every reader below is a keydown handler, which cannot fire before
// the render that produced these callbacks has committed.
const cbRef = useRef<HotkeyCallbacks>(null!);
cbRef.current = {
handleTimelineElementsDelete,
handleTimelineElementSplit,
handleDomEditElementDelete,
handleUndo,
handleRedo,
handleCopy,
handlePaste,
handleCut,
onResetKeyframes,
onDeleteSelectedKeyframes,
onToggleRecording,
onGroupSelection,
onUngroupSelection,
leftSidebarRef,
domEditSelectionRef,
showToast,
};
useEffect(() => {
cbRef.current = {
handleTimelineElementsDelete,
handleTimelineElementSplit,
handleDomEditElementDelete,
handleUndo,
handleRedo,
handleCopy,
handlePaste,
handleCut,
onResetKeyframes,
onDeleteSelectedKeyframes,
onToggleRecording,
onGroupSelection,
onUngroupSelection,
leftSidebarRef,
domEditSelectionRef,
showToast,
};
});

// ── Keydown dispatch ──

Expand Down
93 changes: 93 additions & 0 deletions packages/studio/src/hooks/useBlockCatalog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

type Catalog = Awaited<ReturnType<typeof loadHook>>;

/**
* The catalog is cached in module scope, so each case needs its own module
* instance or the second one reads the first one's answer.
*/
async function loadHook() {
vi.resetModules();
const { useBlockCatalog } = await import("./useBlockCatalog");
return useBlockCatalog;
}

/** One snapshot per render, so the assertions read a list instead of a mutated binding. */
const states: { loading: boolean; error: string | null; blocks: unknown[] }[] = [];

async function render(useBlockCatalog: Catalog) {
function Probe() {
const { blocks, loading, error } = useBlockCatalog();
states.push({ loading, error, blocks });
return null;
}
const root = createRoot(document.createElement("div"));
await act(async () => {
root.render(<Probe />);
});
return () => act(() => root.unmount());
}

beforeEach(() => {
states.length = 0;
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe("useBlockCatalog", () => {
it("ends loading with the fetched blocks", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
json: async () => [{ title: "Fade", description: "", tags: ["transition"] }],
})),
);

const unmount = await render(await loadHook());

const last = states[states.length - 1]!;
expect(last.loading).toBe(false);
expect(last.error).toBeNull();
expect(last.blocks).toHaveLength(1);
unmount();
});

it("ends loading with the failure message when the catalog request rejects", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("offline");
}),
);

const unmount = await render(await loadHook());

const last = states[states.length - 1]!;
expect(last.loading).toBe(false);
expect(last.error).toBe("offline");
expect(last.blocks).toEqual([]);
unmount();
});

it("ends loading with a message when the catalog responds not-ok", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: false, json: async () => [] })),
);

const unmount = await render(await loadHook());

const last = states[states.length - 1]!;
expect(last.loading).toBe(false);
expect(last.error).toBe("Failed to load catalog");
unmount();
});
});
37 changes: 24 additions & 13 deletions packages/studio/src/hooks/useBlockCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,29 +36,40 @@ function loadCatalog(): Promise<CatalogItem[]> {
return catalogRequest;
}

/**
* The catalog fetch, as a value rather than a throw.
*
* The React Compiler cannot reorder across a `finally`, so a `try`/`finally`
* anywhere in a hook body makes it decline the whole hook and silently drop every
* memo in it. Out here the same control flow is just a function, and the effect
* below is left with one branch instead of three clauses.
*/
type CatalogLoad = { readonly items: CatalogItem[] } | { readonly error: string };

async function loadCatalogResult(): Promise<CatalogLoad> {
try {
return { items: await loadCatalog() };
} catch (err) {
return { error: err instanceof Error ? err.message : "Failed to load catalog" };
}
}

export function useBlockCatalog() {
const [blocks, setBlocks] = useState<CatalogItem[]>(() => catalogCache ?? []);
const [loading, setLoading] = useState(catalogCache === null);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [category, setCategory] = useState<BlockCategory | null>(null);

// fallow-ignore-next-line complexity
useEffect(() => {
if (catalogCache) return;
let cancelled = false;
(async () => {
try {
const items = await loadCatalog();
if (cancelled) return;
setBlocks(items);
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load catalog");
} finally {
if (!cancelled) setLoading(false);
}
})();
void loadCatalogResult().then((result) => {
if (cancelled) return;
if ("items" in result) setBlocks(result.items);
else setError(result.error);
setLoading(false);
});
return () => {
cancelled = true;
};
Expand Down
Loading
Loading