feat(packages/excalidraw): tweak and expose more API around state and lifecycle (#10939)

This commit is contained in:
David Luzar
2026-03-11 16:51:03 +01:00
committed by GitHub
parent e73a5b0116
commit 92d25446d6
7 changed files with 364 additions and 138 deletions
@@ -0,0 +1,111 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { getDefaultAppState } from "../appState";
import { ExcalidrawAPIContext } from "../components/App";
import { AppStateObserver } from "../components/AppStateObserver";
import {
useAppStateValue,
useOnAppStateChange,
} from "../hooks/useAppStateValue";
import type { AppState, ExcalidrawImperativeAPI } from "../types";
const createAppState = (): AppState => ({
...getDefaultAppState(),
width: 0,
height: 0,
offsetLeft: 0,
offsetTop: 0,
});
const createMockAPI = (initialState: AppState) => {
let state = initialState;
const observer = new AppStateObserver(() => state);
return {
api: {
isDestroyed: false,
getAppState: () => state,
onStateChange: observer.onStateChange,
} as Pick<
ExcalidrawImperativeAPI,
"isDestroyed" | "getAppState" | "onStateChange"
> as ExcalidrawImperativeAPI,
updateAppState: (partial: Partial<AppState>) => {
const prevState = state;
state = { ...state, ...partial };
observer.flush(prevState);
},
};
};
describe("app state hooks", () => {
afterEach(() => {
cleanup();
});
it("useAppStateValue rerenders when the selected value changes", () => {
const renderSpy = vi.fn();
const { api, updateAppState } = createMockAPI(createAppState());
const ValueConsumer = () => {
const value = useAppStateValue("viewModeEnabled");
renderSpy(value);
return <div data-testid="value">{String(value)}</div>;
};
render(
<ExcalidrawAPIContext.Provider value={api}>
<ValueConsumer />
</ExcalidrawAPIContext.Provider>,
);
expect(screen.getByTestId("value").textContent).toBe("false");
expect(renderSpy).toHaveBeenCalledTimes(1);
act(() => {
updateAppState({ viewModeEnabled: true });
});
expect(screen.getByTestId("value").textContent).toBe("true");
expect(renderSpy).toHaveBeenCalledTimes(2);
});
it("useOnAppStateChange notifies without rerendering", () => {
const renderSpy = vi.fn();
const callback = vi.fn();
const { api, updateAppState } = createMockAPI(createAppState());
const ChangeConsumer = () => {
const value = useOnAppStateChange("viewModeEnabled", callback);
renderSpy(value);
return <div data-testid="value">{String(value)}</div>;
};
render(
<ExcalidrawAPIContext.Provider value={api}>
<ChangeConsumer />
</ExcalidrawAPIContext.Provider>,
);
expect(screen.getByTestId("value").textContent).toBe("undefined");
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
false,
expect.objectContaining({ viewModeEnabled: false }),
);
act(() => {
updateAppState({ viewModeEnabled: true });
});
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledTimes(2);
expect(callback).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ viewModeEnabled: true }),
);
});
});
@@ -41,13 +41,15 @@ describe("event callbacks", () => {
await render(
<Excalidraw
onExcalidrawAPI={(api) =>
lifecyclePromise.resolve({
api: api as ExcalidrawImperativeAPI,
mount: api.onEvent("editor:mount"),
initialize: api.onEvent("editor:initialize"),
})
}
onExcalidrawAPI={(api) => {
if (api) {
lifecyclePromise.resolve({
api,
mount: api.onEvent("editor:mount"),
initialize: api.onEvent("editor:initialize"),
});
}
}}
/>,
);