diff --git a/sandpack-react/src/contexts/utils/useClient.test.ts b/sandpack-react/src/contexts/utils/useClient.test.ts index 06e90ac9b..c2e81299b 100644 --- a/sandpack-react/src/contexts/utils/useClient.test.ts +++ b/sandpack-react/src/contexts/utils/useClient.test.ts @@ -2,6 +2,7 @@ * @jest-environment jsdom */ +import * as sandpackClient from "@codesandbox/sandpack-client"; import { renderHook, act } from "@testing-library/react"; import { getSandpackStateFromProps } from "../../utils/sandpackUtils"; @@ -9,6 +10,65 @@ import { getSandpackStateFromProps } from "../../utils/sandpackUtils"; import { useClient } from "./useClient"; import type { UseClientOperations } from "./useClient"; +const actualSandpackClient = jest.requireActual("@codesandbox/sandpack-client"); + +jest.mock("@codesandbox/sandpack-client", () => { + const actual = jest.requireActual("@codesandbox/sandpack-client"); + + return { + ...actual, + loadSandpackClient: jest.fn((...args) => + actual.loadSandpackClient(...args) + ), + }; +}); + +const mockedLoadSandpackClient = + sandpackClient.loadSandpackClient as jest.MockedFunction< + typeof sandpackClient.loadSandpackClient + >; + +const createMockClient = (iframe: HTMLIFrameElement) => { + return createMockClientController(iframe).client; +}; + +const createMockClientController = (iframe: HTMLIFrameElement) => { + const listeners: sandpackClient.ListenerFunction[] = []; + + return { + client: { + status: "initializing", + iframe, + listen: jest.fn((listener: sandpackClient.ListenerFunction) => { + listeners.push(listener); + + return jest.fn(() => { + const listenerIndex = listeners.indexOf(listener); + + if (listenerIndex >= 0) { + listeners.splice(listenerIndex, 1); + } + }); + }), + dispatch: jest.fn(), + updateSandbox: jest.fn(), + destroy: jest.fn(), + } as unknown as InstanceType, + emit(message: sandpackClient.SandpackMessage) { + listeners.forEach((listener) => { + listener(message); + }); + }, + }; +}; + +beforeEach(() => { + mockedLoadSandpackClient.mockReset(); + mockedLoadSandpackClient.mockImplementation((...args) => + actualSandpackClient.loadSandpackClient(...args) + ); +}); + const getAmountOfListener = ( instance: UseClientOperations, name = "client-id", @@ -358,6 +418,173 @@ describe(useClient, () => { expect(getAmountOfListener(operations, "client-1")).toBe(3); expect(operations.clients["client-2"]).toBe(undefined); }); + + it("restarts sandpack when a new bundler registers after the last client unmounts", async () => { + mockedLoadSandpackClient.mockImplementation(async (iframeSelector) => { + return createMockClient(iframeSelector as HTMLIFrameElement); + }); + + const { result } = renderHook(() => + useClient({}, getSandpackStateFromProps({})) + ); + const operations = result.current[1]; + + await act(async () => { + await operations.registerBundler( + document.createElement("iframe"), + "client-1" + ); + await operations.runSandpack(); + }); + + expect(result.current[0].status).toBe("running"); + expect(operations.clients["client-1"]).toBeDefined(); + + act(() => { + operations.unregisterBundler("client-1"); + }); + + expect(result.current[0].status).toBe("idle"); + expect(operations.clients["client-1"]).toBe(undefined); + + const restartedOperations = result.current[1]; + await act(async () => { + await restartedOperations.registerBundler( + document.createElement("iframe"), + "client-2" + ); + }); + + expect(result.current[0].status).toBe("running"); + expect(Object.keys(restartedOperations.clients)).toEqual(["client-2"]); + expect(mockedLoadSandpackClient).toHaveBeenCalledTimes(2); + }); + + it("replays the latest file update after a client finishes initializing", async () => { + const iframe = document.createElement("iframe"); + const clientController = createMockClientController(iframe); + const props = { + options: { + recompileMode: "immediate" as const, + }, + }; + let filesState = getSandpackStateFromProps(props); + + mockedLoadSandpackClient.mockResolvedValue(clientController.client); + + const { result, rerender } = renderHook(() => + useClient(props, filesState) + ); + const operations = result.current[1]; + + await act(async () => { + await operations.registerBundler(iframe, "client-1"); + await operations.runSandpack(); + }); + + const appPath = + Object.keys(filesState.files).find((path) => path.includes("App")) ?? + Object.keys(filesState.files)[0]; + const existingFile = filesState.files[appPath]; + const nextFiles = { + ...filesState.files, + [appPath]: + typeof existingFile === "string" + ? { code: `${existingFile}\n// queued update` } + : { + ...existingFile, + code: `${existingFile.code}\n// queued update`, + }, + }; + + filesState = { + ...filesState, + files: nextFiles, + shouldUpdatePreview: true, + }; + + await act(async () => { + rerender(); + }); + + expect(clientController.client.updateSandbox).not.toHaveBeenCalled(); + + act(() => { + clientController.emit({ + type: "done", + compilatonError: false, + } as sandpackClient.SandpackMessage); + }); + + expect(clientController.client.updateSandbox).toHaveBeenCalledTimes(1); + expect(clientController.client.updateSandbox).toHaveBeenCalledWith({ + files: nextFiles, + template: filesState.environment, + }); + }); + + it("keeps only the latest client when the same client id reloads before the first load resolves", async () => { + const initialIframe = document.createElement("iframe"); + const staleIframe = document.createElement("iframe"); + const freshIframe = document.createElement("iframe"); + const initialController = createMockClientController(initialIframe); + const staleController = createMockClientController(staleIframe); + const freshController = createMockClientController(freshIframe); + + let releaseStaleLoad!: () => void; + const staleLoadBlocked = new Promise((resolve) => { + releaseStaleLoad = resolve; + }); + + mockedLoadSandpackClient.mockImplementation(async (iframeSelector) => { + const callCount = mockedLoadSandpackClient.mock.calls.length; + + if (callCount === 1) { + return initialController.client; + } + + if (callCount === 2) { + await staleLoadBlocked; + return staleController.client; + } + + return freshController.client; + }); + + const { result } = renderHook(() => + useClient({}, getSandpackStateFromProps({})) + ); + let operations = result.current[1]; + + await act(async () => { + await operations.registerBundler(initialIframe, "client-1"); + await operations.runSandpack(); + }); + + operations = result.current[1]; + let staleRegister!: Promise; + await act(async () => { + staleRegister = operations.registerBundler(staleIframe, "client-1"); + await Promise.resolve(); + }); + + operations = result.current[1]; + let freshRegister!: Promise; + await act(async () => { + freshRegister = operations.registerBundler(freshIframe, "client-1"); + await Promise.resolve(); + }); + + releaseStaleLoad(); + + await act(async () => { + await Promise.all([staleRegister, freshRegister]); + }); + + expect(mockedLoadSandpackClient).toHaveBeenCalledTimes(3); + expect(operations.clients["client-1"]).toBe(freshController.client); + expect(operations.clients["client-1"].iframe).toBe(freshIframe); + }); }); describe("status", () => { diff --git a/sandpack-react/src/contexts/utils/useClient.ts b/sandpack-react/src/contexts/utils/useClient.ts index 13c702720..cbf59819a 100644 --- a/sandpack-react/src/contexts/utils/useClient.ts +++ b/sandpack-react/src/contexts/utils/useClient.ts @@ -36,6 +36,11 @@ interface SandpackConfigState { status: SandpackStatus; } +interface PendingClientUpdate { + files: FilesState["files"]; + template: FilesState["environment"]; +} + export interface ClientPropsOverride { startRoute?: string; } @@ -116,9 +121,121 @@ export const useClient: UseClient = ( >({ global: {} }); const debounceHook = useRef(); const prevEnvironment = useRef(filesState.environment); + const pendingClientUpdates = useRef>({}); + const lastSentClientUpdates = useRef>({}); + const unsubscribePendingClientUpdates = useRef< + Record + >({}); + const clientLoadVersion = useRef>({}); const asyncSandpackId = useAsyncSandpackId(filesState.files); + const clearPendingClientUpdateListener = useCallback( + (clientId: string): void => { + if ( + typeof unsubscribePendingClientUpdates.current[clientId] !== "function" + ) { + return; + } + + unsubscribePendingClientUpdates.current[clientId](); + delete unsubscribePendingClientUpdates.current[clientId]; + }, + [] + ); + + const clearPendingClientUpdate = useCallback( + (clientId: string): void => { + clearPendingClientUpdateListener(clientId); + delete pendingClientUpdates.current[clientId]; + }, + [clearPendingClientUpdateListener] + ); + + const applyClientUpdate = useCallback( + ( + clientId: string, + client: SandpackClientType, + update: PendingClientUpdate + ): void => { + clearPendingClientUpdate(clientId); + lastSentClientUpdates.current[clientId] = update; + client.updateSandbox(update); + }, + [clearPendingClientUpdate] + ); + + const ensurePendingClientUpdateListener = useCallback( + (clientId: string, client: SandpackClientType): void => { + const pendingUpdate = pendingClientUpdates.current[clientId]; + const lastSentUpdate = lastSentClientUpdates.current[clientId]; + + if ( + pendingUpdate && + lastSentUpdate && + pendingUpdate.files === lastSentUpdate.files && + pendingUpdate.template === lastSentUpdate.template + ) { + clearPendingClientUpdate(clientId); + return; + } + + if ( + typeof unsubscribePendingClientUpdates.current[clientId] === "function" + ) { + return; + } + + unsubscribePendingClientUpdates.current[clientId] = client.listen( + (message: SandpackMessage) => { + if (message.type !== "done" || message.compilatonError) { + return; + } + + const pendingUpdate = pendingClientUpdates.current[clientId]; + + if (!pendingUpdate) { + clearPendingClientUpdateListener(clientId); + return; + } + + applyClientUpdate(clientId, client, pendingUpdate); + } + ) as UnsubscribeFunction; + }, + [ + applyClientUpdate, + clearPendingClientUpdate, + clearPendingClientUpdateListener, + ] + ); + + const queuePendingClientUpdate = useCallback( + (clientId: string, client: SandpackClientType): void => { + const nextUpdate = { + files: filesState.files, + template: filesState.environment, + }; + const lastSentUpdate = lastSentClientUpdates.current[clientId]; + + if ( + lastSentUpdate && + lastSentUpdate.files === nextUpdate.files && + lastSentUpdate.template === nextUpdate.template + ) { + return; + } + + pendingClientUpdates.current[clientId] = nextUpdate; + ensurePendingClientUpdateListener(clientId, client); + }, + [ + ensurePendingClientUpdateListener, + filesState.environment, + filesState.files, + ] + ); + /** * Callbacks */ @@ -128,9 +245,13 @@ export const useClient: UseClient = ( clientId: string, clientPropsOverride?: ClientPropsOverride ): Promise => { + const nextLoadVersion = (clientLoadVersion.current[clientId] ?? 0) + 1; + clientLoadVersion.current[clientId] = nextLoadVersion; + // Clean up any existing clients that // have been created with the given id if (clients.current[clientId]) { + clearPendingClientUpdateListener(clientId); clients.current[clientId].destroy(); } @@ -199,6 +320,13 @@ export const useClient: UseClient = ( } ); + if (clientLoadVersion.current[clientId] !== nextLoadVersion) { + client.destroy(); + client.iframe.contentWindow?.location.replace("about:blank"); + client.iframe.removeAttribute("src"); + return; + } + if (typeof unsubscribe.current !== "function") { unsubscribe.current = client.listen(handleMessage); } @@ -236,9 +364,24 @@ export const useClient: UseClient = ( */ }); + lastSentClientUpdates.current[clientId] = { + files: filesState.files, + template: filesState.environment, + }; clients.current[clientId] = client; + setState((prev) => ({ ...prev, status: "running" })); + + if (pendingClientUpdates.current[clientId]) { + ensurePendingClientUpdateListener(clientId, client); + } }, - [filesState.environment, filesState.files, state.reactDevTools] + [ + clearPendingClientUpdateListener, + ensurePendingClientUpdateListener, + filesState.environment, + filesState.files, + state.reactDevTools, + ] ); const unregisterAllClients = useCallback((): void => { @@ -336,34 +479,42 @@ export const useClient: UseClient = ( if (state.status === "running") { await createClient(iframe, clientId, clientPropsOverride); + return; + } + + if ((options?.autorun ?? true) && state.status === "idle") { + await runSandpack(); } }, - [createClient, state.status] + [createClient, options?.autorun, runSandpack, state.status] ); const unregisterBundler = (clientId: string): void => { + clientLoadVersion.current[clientId] = + (clientLoadVersion.current[clientId] ?? 0) + 1; const client = clients.current[clientId]; if (client) { client.destroy(); client.iframe.contentWindow?.location.replace("about:blank"); client.iframe.removeAttribute("src"); delete clients.current[clientId]; - } else { - delete registeredIframes.current[clientId]; } + delete registeredIframes.current[clientId]; + clearPendingClientUpdate(clientId); + delete lastSentClientUpdates.current[clientId]; + if (timeoutHook.current) { clearTimeout(timeoutHook.current); } const unsubscribeQueuedClients = Object.values( unsubscribeClientListeners.current[clientId] ?? {} - ); + ) as UnsubscribeFunction[]; // Unsubscribing all listener registered - unsubscribeQueuedClients.forEach((listenerOfClient) => { - const listenerFunctions = Object.values(listenerOfClient); - listenerFunctions.forEach((unsubscribe) => unsubscribe()); + unsubscribeQueuedClients.forEach((unsubscribe) => { + unsubscribe(); }); // Keep running if it still have clients @@ -525,15 +676,18 @@ export const useClient: UseClient = ( } if (recompileMode === "immediate") { - Object.values(clients.current).forEach((client) => { + Object.entries(clients.current).forEach(([clientId, client]) => { + const nextUpdate = { + files: filesState.files, + template: filesState.environment, + }; /** * Avoid concurrency */ if (client.status === "done") { - client.updateSandbox({ - files: filesState.files, - template: filesState.environment, - }); + applyClientUpdate(clientId, client, nextUpdate); + } else { + queuePendingClientUpdate(clientId, client); } }); } @@ -543,15 +697,18 @@ export const useClient: UseClient = ( window.clearTimeout(debounceHook.current); debounceHook.current = window.setTimeout(() => { - Object.values(clients.current).forEach((client) => { + Object.entries(clients.current).forEach(([clientId, client]) => { + const nextUpdate = { + files: filesState.files, + template: filesState.environment, + }; /** * Avoid concurrency */ if (client.status === "done") { - client.updateSandbox({ - files: filesState.files, - template: filesState.environment, - }); + applyClientUpdate(clientId, client, nextUpdate); + } else { + queuePendingClientUpdate(clientId, client); } }); }, recompileDelay); @@ -568,6 +725,8 @@ export const useClient: UseClient = ( recompileDelay, recompileMode, registerBundler, + applyClientUpdate, + queuePendingClientUpdate, state.status, ] );