diff --git a/.changeset/client-view-tree-lifecycle.md b/.changeset/client-view-tree-lifecycle.md new file mode 100644 index 0000000..b891996 --- /dev/null +++ b/.changeset/client-view-tree-lifecycle.md @@ -0,0 +1,5 @@ +--- +"@playfast/echoform": minor +--- + +Expose client view-tree lifecycle updates so hosts can distinguish empty and pending renders. diff --git a/packages/echoform/src/client/Client.tsx b/packages/echoform/src/client/Client.tsx index 2bcb299..9eb93e2 100644 --- a/packages/echoform/src/client/Client.tsx +++ b/packages/echoform/src/client/Client.tsx @@ -53,22 +53,42 @@ function applyViewDeletion( return [...state.slice(0, runningViewIndex), ...state.slice(runningViewIndex + 1)]; } -interface ClientProps = Record> { +export interface ClientViewsState { + readonly views: ReadonlyArray; + readonly hasReceivedViewTree: boolean; +} + +interface ClientPropsExternalApi = Record> { readonly transport: Transport; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- props are built dynamically by ViewsRenderer readonly views: Readonly>>; readonly requestViewTreeOnMount?: boolean; + readonly onViewsChange?: (state: ClientViewsState) => void; } function Client = Record>({ transport: rawTransport, views, requestViewTreeOnMount = true, -}: ClientProps): React.ReactElement { + onViewsChange, +}: ClientPropsExternalApi): React.ReactElement { const [runningViews, setRunningViews] = useState>([]); + const runningViewsRef = useRef>([]); const transportRef = useRef(undefined); + const hasReceivedViewTreeRef = useRef(false); + const onViewsChangeRef = useRef(onViewsChange); const streamListenersRef = useRef void>>>(new Map()); const pendingReplayRef = useRef>>(new Map()); + onViewsChangeRef.current = onViewsChange; + + const updateRunningViews = useCallback((nextViews: ReadonlyArray): void => { + runningViewsRef.current = nextViews; + setRunningViews(nextViews); + onViewsChangeRef.current?.({ + views: nextViews, + hasReceivedViewTree: hasReceivedViewTreeRef.current, + }); + }, []); const createEvent = useCallback((eventUid: EventUid, ...args: ReadonlyArray): Promise => { return new Promise((resolve, reject) => { @@ -142,17 +162,20 @@ function Client = Record { const transport = decompileTransport(rawTransport); transportRef.current = transport; + hasReceivedViewTreeRef.current = false; + updateRunningViews([]); const updateViewsTreeHandler = ({ views }: AppEvents['update_views_tree']): void => { - setRunningViews(views); + hasReceivedViewTreeRef.current = true; + updateRunningViews(views); }; const updateViewHandler = ({ view }: AppEvents['update_view']): void => { - setRunningViews((state) => applyViewUpdate(state, view)); + updateRunningViews(applyViewUpdate(runningViewsRef.current, view)); }; const deleteViewHandler = ({ viewUid }: AppEvents['delete_view']): void => { - setRunningViews((state) => applyViewDeletion(state, viewUid)); + updateRunningViews(applyViewDeletion(runningViewsRef.current, viewUid)); }; const streamChunkHandler = ({ streamUid, chunk }: AppEvents['stream_chunk']): void => { @@ -209,9 +232,11 @@ function Client = Record; readonly listenerCount: () => number; + readonly dispatch: (frame: Uint8Array) => void; } const READY_VIEW_TREE = { @@ -44,13 +45,16 @@ function dispatchViewTree( function createTestTransport(): TestTransportHarness { const listeners = new Set<(data: Uint8Array) => void>(); + const dispatch = (frame: Uint8Array): void => { + listeners.forEach((listener) => listener(frame)); + }; return { transport: { emit: (_event, message) => { if (message === undefined) return; if (decodeMessage(message).event !== "request_views_tree") return; - queueMicrotask(() => dispatchViewTree(listeners)); + queueMicrotask(() => dispatch(encodeMessage("update_views_tree", READY_VIEW_TREE))); }, on: (_event, handler) => { listeners.add(handler); @@ -60,6 +64,7 @@ function createTestTransport(): TestTransportHarness { }, }, listenerCount: () => listeners.size, + dispatch, }; } @@ -108,4 +113,40 @@ describe("Client", () => { await act(async () => root.unmount()); expect(transportHarness.listenerCount()).toBe(0); }); + + test("reports an empty snapshot before a widget gains renderable views", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + const root = createRoot(container); + const transportHarness = createTestTransport(); + const states = new Set(); + + await act(async () => { + root.render( + states.add(state)} + />, + ); + }); + await act(async () => { + transportHarness.dispatch(encodeMessage("update_views_tree", { views: [] })); + }); + + expect([...states].at(-1)).toEqual({ views: [], hasReceivedViewTree: true }); + expect(container.textContent).toBe(""); + + await act(async () => { + transportHarness.dispatch(encodeMessage("update_views_tree", READY_VIEW_TREE)); + }); + + expect([...states].at(-1)).toEqual({ + views: READY_VIEW_TREE.views, + hasReceivedViewTree: true, + }); + expect(container.textContent).toBe("ready"); + + await act(async () => root.unmount()); + }); });