import { act, cleanup, render } from "@testing-library/react"; import * as React from "react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../../schema/define-toolcraft"; import type { ToolcraftExternalStore } from "../../state/toolcraft-external-store"; import type { ToolcraftState } from "../../state/types"; import { CanvasShell } from "../canvas/canvas-shell"; import { ControlsPanel } from "../controls-panel/controls-panel"; import { LayersPanel } from "../layers/layers-panel"; import { ToolcraftPanelHost } from "../panel-host/panel-host"; import { TimelinePanel } from "../timeline/timeline-panel"; import { ToolbarPanel } from "./toolbar-panel"; import { ToolcraftRoot } from "./toolcraft-root"; import { useToolcraftCommittedSelector, useToolcraftDependencySelector, } from "./toolcraft-selectors"; import { useToolcraftStore } from "./toolcraft-store-context"; const isolationSchema = defineToolcraft({ canvas: { enabled: true }, panels: { controls: { sections: [ { controls: { opacity: { defaultValue: 75, target: "shape.opacity", type: "slider", }, }, }, ], title: "Controls", }, layers: true, timeline: { mode: "playback" }, }, persistence: { include: ["canvas", "panels", "timeline"], key: "toolcraft:render-isolation:state:v1", storage: "localStorage", version: 1, }, toolbar: { history: true, radar: true, theme: true, zoom: true, }, }); type CommitName = | "canvas" | "controls" | "layers" | "panel" | "playback" | "toolbar"; function createCommitCounts(): Record { return { canvas: 0, controls: 0, layers: 0, panel: 0, playback: 0, toolbar: 0, }; } function CommitCounter({ children, counts, name, }: { children: React.ReactNode; counts: Record; name: CommitName; }): React.JSX.Element { return ( { if (phase === "update") { counts[name] += 1; } }} > {children} ); } function CaptureStore({ capture, }: { capture: (store: ToolcraftExternalStore) => void; }): null { capture(useToolcraftStore()); return null; } function ControlsSelectorProbe({ execute, }: { execute: ReturnType; }): null { const selector = React.useCallback( (state: ToolcraftState) => { execute(); return state; }, [execute], ); useToolcraftCommittedSelector(selector); return null; } function PlaybackSelectorProbe({ execute, }: { execute: ReturnType; }): null { useToolcraftDependencySelector( (state) => { execute(); return state.timeline.currentTimeSeconds; }, Object.is, [{ kind: "playback" }], ); return null; } function ViewportSelectorProbe({ execute, }: { execute: ReturnType; }): null { useToolcraftDependencySelector( (state) => { execute(); return `${state.canvas.offset.x}:${state.canvas.offset.y}:${state.canvas.zoom}`; }, Object.is, [{ kind: "viewport.offset" }, { kind: "viewport.zoom" }], ); return null; } function IsolationHarness({ capture, counts, selectorExecutions, }: { capture: (store: ToolcraftExternalStore) => void; counts: Record; selectorExecutions: { controls: ReturnType; playback: ReturnType; viewport: ReturnType; }; }): React.JSX.Element { return ( Panel
); } beforeAll(() => { Object.defineProperty(window, "matchMedia", { configurable: true, value: (query: string) => ({ addEventListener: () => undefined, addListener: () => undefined, dispatchEvent: () => false, matches: query === "(prefers-reduced-motion: reduce)", media: query, onchange: null, removeEventListener: () => undefined, removeListener: () => undefined, }), }); }); afterEach(() => { vi.useRealTimers(); cleanup(); window.localStorage.clear(); vi.restoreAllMocks(); }); describe("Toolcraft hot render isolation", () => { it("keeps controls cold when committing a transient playback update", () => { const counts = createCommitCounts(); const selectorExecutions = { controls: vi.fn(), playback: vi.fn(), viewport: vi.fn(), }; let store: ToolcraftExternalStore | undefined; render( { store = value; }} counts={counts} selectorExecutions={selectorExecutions} />, ); const initialExecutions = { controls: selectorExecutions.controls.mock.calls.length, playback: selectorExecutions.playback.mock.calls.length, viewport: selectorExecutions.viewport.mock.calls.length, }; act(() => { store?.dispatchTransient({ currentTimeSeconds: 1, type: "timeline.setCurrentTime", }); }); expect(counts).toEqual({ canvas: 0, controls: 0, layers: 0, panel: 0, playback: 1, toolbar: 0, }); expect(selectorExecutions.playback.mock.calls.length).toBeGreaterThan( initialExecutions.playback, ); expect(selectorExecutions.controls).toHaveBeenCalledTimes( initialExecutions.controls, ); expect(selectorExecutions.viewport).toHaveBeenCalledTimes( initialExecutions.viewport, ); act(() => { store?.commitTransient("playback"); }); expect(counts).toEqual({ canvas: 0, controls: 0, layers: 0, panel: 0, playback: 2, toolbar: 0, }); expect(selectorExecutions.controls.mock.calls.length).toBeGreaterThan( initialExecutions.controls, ); const controlsExecutionsAfterTransientCommit = selectorExecutions.controls.mock.calls.length; act(() => { store?.dispatch({ target: "shape.opacity", type: "controls.setValue", value: 80, }); }); expect(counts.controls).toBeGreaterThan(0); expect(selectorExecutions.controls.mock.calls.length).toBeGreaterThan( controlsExecutionsAfterTransientCommit, ); }); it("keeps controls cold when committing transient viewport moves", () => { const counts = createCommitCounts(); const selectorExecutions = { controls: vi.fn(), playback: vi.fn(), viewport: vi.fn(), }; let store: ToolcraftExternalStore | undefined; render( { store = value; }} counts={counts} selectorExecutions={selectorExecutions} />, ); const initialExecutions = { controls: selectorExecutions.controls.mock.calls.length, playback: selectorExecutions.playback.mock.calls.length, viewport: selectorExecutions.viewport.mock.calls.length, }; act(() => { for (let index = 1; index <= 5; index += 1) { store?.dispatchTransient({ offset: { x: index * 3, y: index * -2 }, type: "canvas.setViewport", zoom: 100 + index, }); } }); expect(counts).toEqual({ canvas: 1, controls: 0, layers: 0, panel: 0, playback: 0, toolbar: 0, }); expect(selectorExecutions.viewport.mock.calls.length).toBeGreaterThan( initialExecutions.viewport, ); expect(selectorExecutions.controls).toHaveBeenCalledTimes( initialExecutions.controls, ); expect(selectorExecutions.playback).toHaveBeenCalledTimes( initialExecutions.playback, ); act(() => { store?.commitTransient("viewport"); }); expect(counts).toEqual({ canvas: 1, controls: 0, layers: 0, panel: 0, playback: 0, toolbar: 1, }); }); it("does not schedule persistence until one transient commit boundary", () => { vi.useFakeTimers(); const setItem = vi.spyOn(Storage.prototype, "setItem"); let store: ToolcraftExternalStore | undefined; render( { store = value; }} /> , ); act(() => { vi.advanceTimersByTime(120); }); setItem.mockClear(); act(() => { store?.dispatchTransient({ currentTimeSeconds: 1, type: "timeline.setCurrentTime", }); for (let index = 1; index <= 5; index += 1) { store?.dispatchTransient({ offset: { x: index, y: -index }, type: "canvas.setOffset", }); } vi.advanceTimersByTime(240); }); expect(setItem).not.toHaveBeenCalled(); act(() => { store?.commitTransient("viewport"); vi.advanceTimersByTime(119); }); expect(setItem).not.toHaveBeenCalled(); act(() => { vi.advanceTimersByTime(1); }); expect(setItem).toHaveBeenCalledTimes(1); }); });