diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f985e72d..57960c845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - The macOS menu bar icon shows the Termix logo instead of an empty square - Cmd+1 (Alt+1 on Windows and Linux) and the other number shortcuts pick the open tabs as numbered in the tab bar, not the home tab - A plugin the app no longer ships and could not download (for example on a locked down network) shows as failed with the reason on the Plugins tab, instead of as installed +- The desktop app no longer shows This layout could not be shown when the layout is asked for before its plugin has finished starting; it tries again ## 26.10.5 diff --git a/src/ui/plugin-host/loader.ts b/src/ui/plugin-host/loader.ts index d4ccc61e2..a208767fa 100644 --- a/src/ui/plugin-host/loader.ts +++ b/src/ui/plugin-host/loader.ts @@ -85,13 +85,47 @@ function absolute(url: string): string { return new URL(url, window.location.href).href; } +const IMPORT_RETRY_DELAYS_MS = [500, 1500, 3000]; + +/** + * A bundle asked for while its plugin is still starting (a slow desktop boot) + * answers 404, and the browser then keeps that failure for the URL. So a + * failed import is tried again a few times, each under a new URL. + */ +export async function importWithRetry( + url: string, + load: (url: string) => Promise, + sleep: (ms: number) => Promise = (ms) => + new Promise((resolve) => setTimeout(resolve, ms)), +): Promise { + for (let attempt = 0; ; attempt++) { + const next = + attempt === 0 + ? url + : `${url}${url.includes("?") ? "&" : "?"}retry=${attempt}`; + try { + return await load(next); + } catch (error) { + // A bundle that loaded but threw is broken, not late. + const fetchFailed = + error instanceof TypeError && + /dynamically imported module|importing a module script/i.test( + error.message, + ); + if (!fetchFailed || attempt >= IMPORT_RETRY_DELAYS_MS.length) throw error; + await sleep(IMPORT_RETRY_DELAYS_MS[attempt]); + } + } +} + const defaultDeps: PluginLoaderDeps = { fetchPlugins: getPlugins, async importFrontend(summary) { - return (await import( - /* @vite-ignore */ absolute(assetUrl(summary, "frontend.js")) - )) as FrontendModule; + return importWithRetry( + absolute(assetUrl(summary, "frontend.js")), + (url) => import(/* @vite-ignore */ url) as Promise, + ); }, async loadLocale(summary, file) { diff --git a/src/ui/tests/plugin-host/loader.test.tsx b/src/ui/tests/plugin-host/loader.test.tsx index 3b7c390b5..af329ab4f 100644 --- a/src/ui/tests/plugin-host/loader.test.tsx +++ b/src/ui/tests/plugin-host/loader.test.tsx @@ -22,6 +22,7 @@ import { startPluginRuntime, startPreLoginPlugins, syncPlugins, + importWithRetry, } from "@/plugin-host/loader"; import { getPluginRecord, @@ -522,3 +523,29 @@ describe("layouts and themes", () => { ); }); }); + +describe("importWithRetry", () => { + const noWait = async () => {}; + + it("tries a bundle that failed to load again under a new URL", async () => { + const load = vi + .fn() + .mockRejectedValueOnce( + new TypeError("Failed to fetch dynamically imported module: x"), + ) + .mockResolvedValueOnce({ ok: true }); + await expect( + importWithRetry("http://h/plugin-assets/a/frontend.js?v=1", load, noWait), + ).resolves.toEqual({ ok: true }); + expect(load.mock.calls.map((call) => call[0])).toEqual([ + "http://h/plugin-assets/a/frontend.js?v=1", + "http://h/plugin-assets/a/frontend.js?v=1&retry=1", + ]); + }); + + it("does not retry a bundle that threw while running", async () => { + const load = vi.fn().mockRejectedValue(new Error("boom")); + await expect(importWithRetry("u", load, noWait)).rejects.toThrow("boom"); + expect(load).toHaveBeenCalledTimes(1); + }); +});