fix: retry a plugin bundle that was not ready yet (#1708)

This commit is contained in:
LukeGus committed 2026-10-29 13:59:26 -05:00
1 parent 95a00d841c
commit b211311530
3 files changed
+65 -3

No files matched your search

+1
View File
@@ -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
+37 -3
View File
@@ -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<T>(
url: string,
load: (url: string) => Promise<T>,
sleep: (ms: number) => Promise<void> = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms)),
): Promise<T> {
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<FrontendModule>,
);
},
async loadLocale(summary, file) {
+27
View File
@@ -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);
});
});