diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index 9a83e210..e4129451 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -58,6 +58,7 @@ if (typeof window !== 'undefined') { }; const prefetchAll = () => { void import('./pages/Analytics/Analytics'); + void import('@toolui/registry').then((m) => m.preloadToolUi()); }; const ric = (window as any).requestIdleCallback as | ((cb: () => void, opts?: { timeout?: number }) => number) diff --git a/frontend/src/toolui/VendoredToolUi.tsx b/frontend/src/toolui/VendoredToolUi.tsx index b9f3ce3a..53cc1d3f 100644 --- a/frontend/src/toolui/VendoredToolUi.tsx +++ b/frontend/src/toolui/VendoredToolUi.tsx @@ -45,10 +45,11 @@ const warnedShapes = new Set(); // fallback boundary lost its retry above the memoized bubble (measured 2026-09-05: chunk loaded, module resolved, skeleton // forever), so the component arrives through state, which re-renders THIS component no matter what memo sits above it. const loadedComponents = new Map>>(); -function componentFor(entry: ToolUiEntry): Promise> { +export function componentFor(entry: ToolUiEntry): Promise> { let p = loadedComponents.get(entry); if (!p) { - p = entry.load(); + // A rejection is forgotten, so the next widget of this kind fetches again instead of inheriting one blip for the rest of the page. + p = entry.load().catch((e) => { if (loadedComponents.get(entry) === p) loadedComponents.delete(entry); throw e; }); loadedComponents.set(entry, p); } return p; diff --git a/frontend/src/toolui/registry.tsx b/frontend/src/toolui/registry.tsx index 4280459e..ed40a5c8 100644 --- a/frontend/src/toolui/registry.tsx +++ b/frontend/src/toolui/registry.tsx @@ -133,3 +133,19 @@ export const TOOL_UI_REGISTRY: Record = { export function isToolUiComponent(name: string): boolean { return Object.prototype.hasOwnProperty.call(TOOL_UI_REGISTRY, name); } + +let warmed = false; +/** Warm every widget chunk and schema once the page is idle, so the first ShowUI in a chat mounts as content rather than a skeleton; a failed warm is silent and the widget's own mount retries it. */ +export function preloadToolUi(): void { + if (warmed) return; + warmed = true; + const run = (): void => { + for (const e of Object.values(TOOL_UI_REGISTRY)) { + e.load().catch(() => {}); + e.loadSchema().catch(() => {}); + } + }; + if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 4000 }); + else setTimeout(run, 1500); +} + diff --git a/frontend/src/toolui/vendoredOwnsReadiness.test.ts b/frontend/src/toolui/vendoredOwnsReadiness.test.ts index 91696eba..36e6151a 100644 --- a/frontend/src/toolui/vendoredOwnsReadiness.test.ts +++ b/frontend/src/toolui/vendoredOwnsReadiness.test.ts @@ -23,3 +23,24 @@ test('every registry entry exposes a plain loader and none is a React.lazy', () assert.equal(entries, 26); assert.equal(loaders, entries, 'one load() per entry'); }); + +test('a failed chunk load is not remembered: the next mount fetches again', async () => { + const { componentFor } = await import('./VendoredToolUi'); + let calls = 0; + const entry = { load: () => { calls += 1; return calls === 1 ? Promise.reject(new Error('blip')) : Promise.resolve(() => null); }, loadSchema: () => Promise.resolve({} as never) }; + await assert.rejects(componentFor(entry as never)); + const second = await componentFor(entry as never); + assert.equal(typeof second, 'function'); + assert.equal(calls, 2, 'the rejection was forgotten and the load ran again'); + const third = await componentFor(entry as never); + assert.equal(third, second); + assert.equal(calls, 2, 'a success IS remembered'); +}); + +test('the widget chunks are warmed at idle from the app shell', () => { + const main = fs.readFileSync(path.join(process.cwd(), 'src/app/Main.tsx'), 'utf8'); + assert.ok(main.includes("import('@toolui/registry').then((m) => m.preloadToolUi())")); + const reg = fs.readFileSync(path.join(process.cwd(), 'src/toolui/registry.tsx'), 'utf8'); + assert.ok(reg.includes('export function preloadToolUi')); +}); +