Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/client-view-tree-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@playfast/echoform": minor
---

Expose client view-tree lifecycle updates so hosts can distinguish empty and pending renders.
37 changes: 31 additions & 6 deletions packages/echoform/src/client/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,22 +53,42 @@ function applyViewDeletion(
return [...state.slice(0, runningViewIndex), ...state.slice(runningViewIndex + 1)];
}

interface ClientProps<TEvents extends Record<string | number, unknown> = Record<string, unknown>> {
export interface ClientViewsState {
readonly views: ReadonlyArray<ExistingSharedViewData>;
readonly hasReceivedViewTree: boolean;
}

interface ClientPropsExternalApi<TEvents extends Record<string | number, unknown> = Record<string, unknown>> {
readonly transport: Transport<TEvents>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- props are built dynamically by ViewsRenderer
readonly views: Readonly<Record<string, React.ComponentType<any>>>;
readonly requestViewTreeOnMount?: boolean;
readonly onViewsChange?: (state: ClientViewsState) => void;
}

function Client<TEvents extends Record<string | number, unknown> = Record<string, unknown>>({
transport: rawTransport,
views,
requestViewTreeOnMount = true,
}: ClientProps<TEvents>): React.ReactElement {
onViewsChange,
}: ClientPropsExternalApi<TEvents>): React.ReactElement {
const [runningViews, setRunningViews] = useState<ReadonlyArray<ExistingSharedViewData>>([]);
const runningViewsRef = useRef<ReadonlyArray<ExistingSharedViewData>>([]);
const transportRef = useRef<DecompileTransport | undefined>(undefined);
const hasReceivedViewTreeRef = useRef(false);
const onViewsChangeRef = useRef(onViewsChange);
const streamListenersRef = useRef<Map<StreamUid, Set<(chunk: SerializableValue) => void>>>(new Map());
const pendingReplayRef = useRef<Map<StreamUid, ReadonlyArray<SerializableValue>>>(new Map());
onViewsChangeRef.current = onViewsChange;

const updateRunningViews = useCallback((nextViews: ReadonlyArray<ExistingSharedViewData>): void => {
runningViewsRef.current = nextViews;
setRunningViews(nextViews);
onViewsChangeRef.current?.({
views: nextViews,
hasReceivedViewTree: hasReceivedViewTreeRef.current,
});
}, []);

const createEvent = useCallback((eventUid: EventUid, ...args: ReadonlyArray<SerializableValue>): Promise<SerializableValue> => {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -142,17 +162,20 @@ function Client<TEvents extends Record<string | number, unknown> = Record<string
useEffect(() => {
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 => {
Expand Down Expand Up @@ -209,9 +232,11 @@ function Client<TEvents extends Record<string | number, unknown> = Record<string
unsubscribeStreamReplay?.();
streamListenersRef.current = new Map();
pendingReplayRef.current = new Map();
runningViewsRef.current = [];
hasReceivedViewTreeRef.current = false;
transport.destroy();
};
}, [rawTransport, requestViewTreeOnMount]);
}, [rawTransport, requestViewTreeOnMount, updateRunningViews]);

return (
<ViewsRenderer
Expand Down
1 change: 1 addition & 0 deletions packages/echoform/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Client from "./Client";
export type { ClientViewsState } from "./Client";

export type { InferClientProps } from "../shared/view-inference";

Expand Down
45 changes: 43 additions & 2 deletions packages/echoform/tests/client-strict-mode.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import React, { StrictMode, act } from "react";
import { Client } from "../src/client";
import { Client, type ClientViewsState } from "../src/client";
import { decodeMessage, encodeMessage } from "../src/shared/binary-protocol";
import type { Transport } from "../src/shared/types";

Expand All @@ -16,6 +16,7 @@ interface StatusProps {
interface TestTransportHarness {
readonly transport: Transport<BinaryEvents>;
readonly listenerCount: () => number;
readonly dispatch: (frame: Uint8Array) => void;
}

const READY_VIEW_TREE = {
Expand Down Expand Up @@ -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);
Expand All @@ -60,6 +64,7 @@ function createTestTransport(): TestTransportHarness {
},
},
listenerCount: () => listeners.size,
dispatch,
};
}

Expand Down Expand Up @@ -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<ClientViewsState>();

await act(async () => {
root.render(
<Client
transport={transportHarness.transport}
views={{ Status }}
onViewsChange={(state) => 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());
});
});
Loading