diff --git a/.changeset/fuzzy-trees-fetch.md b/.changeset/fuzzy-trees-fetch.md new file mode 100644 index 00000000..4cf5c5df --- /dev/null +++ b/.changeset/fuzzy-trees-fetch.md @@ -0,0 +1,5 @@ +--- +"@proofkit/webviewer": patch +--- + +Retry fmFetch when FileMaker bridge is briefly unavailable and keep callback-mode dispatch failures catchable. diff --git a/packages/webviewer/src/main.ts b/packages/webviewer/src/main.ts index 71ff8812..87c10c8d 100644 --- a/packages/webviewer/src/main.ts +++ b/packages/webviewer/src/main.ts @@ -1,6 +1,9 @@ import { v4 } from "uuid"; let webViewerName: string; +const FM_FETCH_FILEMAKER_RETRY_DELAYS_MS = [250, 500, 1000] as const; +const FILEMAKER_UNAVAILABLE_MESSAGE = "'window.FileMaker' was not available"; + /** * @private * set the name of the Web Viewer to use for all fetches @@ -36,15 +39,15 @@ export function fmFetch( * @param cb callback function to call when the script is done */ callback: () => void, -): void; +): Promise; export function fmFetch(scriptName: string, data: string | object, callback?: () => void) { if (callback) { - return _execScript(scriptName, data, callback); + return _execScriptWithFileMakerRetry(scriptName, data, callback); } - return new Promise((resolve) => { - _execScript(scriptName, data, (result) => { + return new Promise((resolve, reject) => { + _execScriptWithFileMakerRetry(scriptName, data, (result) => { resolve(result); - }); + }).catch(reject); }); } @@ -83,7 +86,37 @@ function _execScript(scriptName: string, data: unknown, cb: (arg0?: unknown) => data, callback: { fetchId, fn: "handleFmWVFetchCallback", webViewerName }, }; - callFMScript(scriptName, param); + try { + callFMScript(scriptName, param); + } catch (error) { + delete cbs[fetchId]; + throw error; + } +} + +function isFileMakerUnavailableError(error: unknown): boolean { + return error instanceof Error && error.message.includes(FILEMAKER_UNAVAILABLE_MESSAGE); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function _execScriptWithFileMakerRetry(scriptName: string, data: unknown, cb: (arg0?: unknown) => void) { + for (let attempt = 0; ; attempt++) { + try { + _execScript(scriptName, data, cb); + return; + } catch (error) { + const delay = FM_FETCH_FILEMAKER_RETRY_DELAYS_MS[attempt]; + if (!(delay && isFileMakerUnavailableError(error))) { + throw error; + } + await wait(delay); + } + } } /** diff --git a/packages/webviewer/tests/main.test.ts b/packages/webviewer/tests/main.test.ts new file mode 100644 index 00000000..d7cef887 --- /dev/null +++ b/packages/webviewer/tests/main.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("fmFetch", () => { + const originalWindow = globalThis.window; + + beforeEach(() => { + vi.useFakeTimers(); + vi.resetModules(); + globalThis.window = {} as Window & typeof globalThis; + }); + + afterEach(() => { + vi.useRealTimers(); + if (typeof originalWindow === "undefined") { + Reflect.deleteProperty(globalThis, "window"); + } else { + globalThis.window = originalWindow; + } + }); + + it("retries when FileMaker is not yet available", async () => { + const { fmFetch } = await import("../src/main.ts"); + const performScript = vi.fn(); + + const result = fmFetch("LoadData", { id: "123" }); + globalThis.window.FileMaker = { + PerformScript: performScript, + PerformScriptWithOption: vi.fn(), + }; + + await vi.advanceTimersByTimeAsync(250); + + expect(performScript).toHaveBeenCalledTimes(1); + const params = JSON.parse(String(performScript.mock.calls[0]?.[1])) as { + callback: { fetchId: string }; + }; + globalThis.window.handleFmWVFetchCallback(JSON.stringify({ ok: true }), params.callback.fetchId); + await vi.advanceTimersByTimeAsync(1); + + await expect(result).resolves.toEqual({ ok: true }); + }); + + it("rejects after three FileMaker availability retries", async () => { + const { fmFetch } = await import("../src/main.ts"); + + const result = fmFetch("LoadData", { id: "123" }); + const rejection = expect(result).rejects.toThrow("'window.FileMaker' was not available"); + await vi.advanceTimersByTimeAsync(1750); + + await rejection; + }); + + it("returns a catchable rejection in callback mode after retries", async () => { + const { fmFetch } = await import("../src/main.ts"); + + const result = fmFetch("LoadData", { id: "123" }, vi.fn()); + const rejection = expect(result).rejects.toThrow("'window.FileMaker' was not available"); + await vi.advanceTimersByTimeAsync(1750); + + await rejection; + }); +});