fix(studio): a host that swaps its multi-clip trim handler gets the new one (#4630)

This commit is contained in:
Miguel Ángel
2026-09-28 06:03:42 +00:00
committed by GitHub
parent 358c04e2e2
commit ff14263c03
2 changed files with 76 additions and 23 deletions
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
import { TimelineEditProvider, useTimelineEditContext } from "./TimelineEditContext";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function mountProvider() {
const seen: TimelineEditCallbacks[] = [];
function Consumer() {
seen.push(useTimelineEditContext());
return null;
}
const root = createRoot(document.createElement("div"));
const render = (value: TimelineEditCallbacks) =>
act(() =>
root.render(
<TimelineEditProvider value={value}>
<Consumer />
</TimelineEditProvider>,
),
);
return { seen, render, unmount: () => act(() => root.unmount()) };
}
describe("TimelineEditProvider", () => {
it("hands consumers the latest multi-clip resize callback", () => {
const { seen, render, unmount } = mountProvider();
const onMoveElement = vi.fn();
const staleResize = vi.fn();
const currentResize = vi.fn();
render({ onMoveElement, onResizeElements: staleResize });
render({ onMoveElement, onResizeElements: currentResize });
expect(seen.at(-1)!.onResizeElements).toBe(currentResize);
unmount();
});
it("keeps the same bag when the parent passes the same callbacks in a new object", () => {
const { seen, render, unmount } = mountProvider();
const onMoveElement = vi.fn();
const onResizeElements = vi.fn();
render({ onMoveElement, onResizeElements });
render({ onMoveElement, onResizeElements });
expect(seen.at(-1)).toBe(seen[0]);
unmount();
});
});
@@ -25,6 +25,32 @@ export function useTimelineEditContextValue(): TimelineEditCallbacks | null {
return useContext(TimelineEditContext);
}
const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
onMoveElement: true,
onMoveElements: true,
onResizeElement: true,
onResizeElements: true,
onToggleTrackHidden: true,
onSetAudioGroupAttributeLive: true,
onSetAudioGroupAttributeQuiet: true,
onRevertAudioGroupAttributeLive: true,
onGroupClips: true,
onSetElementAttributeLive: true,
onSetElementAttributeQuiet: true,
onRevertElementAttributeLive: true,
onBlockedEditAttempt: true,
onSplitElement: true,
onRazorSplit: true,
onRazorSplitAll: true,
onDeleteKeyframe: true,
onDeleteAllKeyframes: true,
onMoveKeyframeToPlayhead: true,
onMoveKeyframe: true,
onToggleKeyframeAtPlayhead: true,
onTogglePropertyGroupKeyframe: true,
};
const EDIT_CALLBACK_KEYS = Object.keys(EDIT_CALLBACK_KEY_SET) as (keyof TimelineEditCallbacks)[];
export function TimelineEditProvider({
value,
children,
@@ -37,29 +63,7 @@ export function TimelineEditProvider({
// Each callback is a stable reference from the parent — memoize the bag
// so consumers don't re-render when unrelated parent state changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
value.onMoveElement,
value.onMoveElements,
value.onResizeElement,
value.onToggleTrackHidden,
value.onSetAudioGroupAttributeLive,
value.onSetAudioGroupAttributeQuiet,
value.onRevertAudioGroupAttributeLive,
value.onGroupClips,
value.onSetElementAttributeLive,
value.onSetElementAttributeQuiet,
value.onRevertElementAttributeLive,
value.onBlockedEditAttempt,
value.onSplitElement,
value.onRazorSplit,
value.onRazorSplitAll,
value.onDeleteKeyframe,
value.onDeleteAllKeyframes,
value.onMoveKeyframeToPlayhead,
value.onMoveKeyframe,
value.onToggleKeyframeAtPlayhead,
value.onTogglePropertyGroupKeyframe,
],
EDIT_CALLBACK_KEYS.map((key) => value[key]),
);
return <TimelineEditContext.Provider value={memoized}>{children}</TimelineEditContext.Provider>;
}