[eric] toolui: the vendored loader owns its readiness; a code-split widget under a fallback boundary stayed a skeleton forever above the memoized bubble

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-09-06 12:31:01 -07:00
co-authored by Claude Fable 5.1
parent 548a55a6d2
commit 9ce20689b9
3 changed files with 76 additions and 40 deletions
+22 -11
View File
@@ -1,6 +1,6 @@
import React, { Suspense, useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useThemeMode } from '@/shared/styles/ThemeContext';
import { TOOL_UI_REGISTRY } from './registry';
import { TOOL_UI_REGISTRY, type ToolUiEntry } from './registry';
import { parseLeniently, type Gate } from './parseLeniently';
interface GuardProps { name: string; quiet?: boolean; children: React.ReactNode }
@@ -41,6 +41,18 @@ interface VendoredToolUiProps {
}
const warnedShapes = new Set<string>();
// One import per component for the whole page, and the loader owns its own readiness: a code-split component behind a
// 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<ToolUiEntry, Promise<React.ComponentType<any>>>();
function componentFor(entry: ToolUiEntry): Promise<React.ComponentType<any>> {
let p = loadedComponents.get(entry);
if (!p) {
p = entry.load();
loadedComponents.set(entry, p);
}
return p;
}
// Rough resting height per component family so the loading skeleton reserves believable space
// (Lobe/Open WebUI pattern: a breathing block where the card will land, not a tiny sliver).
@@ -69,16 +81,18 @@ function VendoredToolUi({ name, props, extraProps, quietFail = false }: Vendored
const { mode } = useThemeMode();
const entry = TOOL_UI_REGISTRY[name];
const [gate, setGate] = useState<Gate>({ state: 'pending' });
const [Component, setComponent] = useState<React.ComponentType<any> | null>(null);
// Parents rebuild the props object every render; keying the validation on identity re-ran an async zod parse per transcript render (real typing-lag cost in table-bearing chats). Content is the real dependency.
const propsKey = useMemo(() => { try { return JSON.stringify(props); } catch { return String(Math.random()); } }, [props]);
useEffect(() => {
let cancelled = false;
if (!entry) return undefined;
entry
.loadSchema()
.then((schema) => {
if (!cancelled) setGate(parseLeniently(schema, props));
Promise.all([entry.loadSchema(), componentFor(entry)])
.then(([schema, Loaded]) => {
if (cancelled) return;
setComponent(() => Loaded);
setGate(parseLeniently(schema, props));
})
.catch(() => { if (!cancelled) setGate({ state: 'bad', problem: 'component failed to load' }); });
return () => { cancelled = true; };
@@ -101,16 +115,13 @@ function VendoredToolUi({ name, props, extraProps, quietFail = false }: Vendored
</div>
);
}
if (gate.state === 'pending') {
if (gate.state === 'pending' || !Component) {
return quietFail ? null : <SkeletonBlock name={name} />;
}
const Component = entry.Component;
return (
<div className={`tool-ui-scope${mode === 'dark' ? ' dark' : ''}`}>
<ComponentGuard name={name} quiet={quietFail}>
<Suspense fallback={<SkeletonBlock name={name} />}>
<Component {...gate.parsed} {...(extraProps || {})} />
</Suspense>
<Component {...gate.parsed} {...(extraProps || {})} />
</ComponentGuard>
</div>
);
+29 -29
View File
@@ -1,10 +1,10 @@
import React, { lazy } from 'react';
import React from 'react';
import type { ZodType } from 'zod';
import './toolui.css';
/** One vendored tool-ui component: lazy renderer + the upstream Serializable wire schema. */
/** One vendored tool-ui component: a loader for the renderer + the upstream Serializable wire schema. */
export interface ToolUiEntry {
Component: React.LazyExoticComponent<React.ComponentType<any>>;
load: () => Promise<React.ComponentType<any>>;
loadSchema: () => Promise<ZodType<any>>;
}
@@ -21,111 +21,111 @@ function wrapAsPost<P extends { id?: unknown }>(Inner: React.ComponentType<{ pos
export const TOOL_UI_REGISTRY: Record<string, ToolUiEntry> = {
'audio': {
Component: lazy(() => import('./components/audio').then((m) => ({ default: m.Audio }))),
load: () => import('./components/audio').then((m) => m.Audio),
loadSchema: () => import('./components/audio/schema').then((m) => m.SerializableAudioSchema),
},
'chart': {
Component: lazy(() => import('./components/chart').then((m) => ({ default: m.Chart }))),
load: () => import('./components/chart').then((m) => m.Chart),
loadSchema: () => import('./components/chart/schema').then((m) => m.SerializableChartSchema),
},
'code-block': {
Component: lazy(() => import('./components/code-block').then((m) => ({ default: m.CodeBlock }))),
load: () => import('./components/code-block').then((m) => m.CodeBlock),
loadSchema: () => import('./components/code-block/schema').then((m) => m.SerializableCodeBlockSchema),
},
'code-diff': {
Component: lazy(() => import('./components/code-diff').then((m) => ({ default: m.CodeDiff }))),
load: () => import('./components/code-diff').then((m) => m.CodeDiff),
loadSchema: () => import('./components/code-diff/schema').then((m) => m.SerializableCodeDiffSchema),
},
'geo-map': {
Component: lazy(() => import('./components/geo-map').then((m) => ({ default: m.GeoMap }))),
load: () => import('./components/geo-map').then((m) => m.GeoMap),
loadSchema: () => import('./components/geo-map/schema').then((m) => m.SerializableGeoMapSchema),
},
'approval-card': {
Component: lazy(() => import('./components/approval-card').then((m) => ({ default: m.ApprovalCard }))),
load: () => import('./components/approval-card').then((m) => m.ApprovalCard),
loadSchema: () => import('./components/approval-card/schema').then((m) => m.SerializableApprovalCardSchema),
},
'citation': {
Component: lazy(() => import('./components/citation').then((m) => ({ default: m.Citation }))),
load: () => import('./components/citation').then((m) => m.Citation),
loadSchema: () => import('./components/citation/schema').then((m) => m.SerializableCitationSchema),
},
'data-table': {
// Force the real grid (.Table) instead of the responsive default: every chat surface we render
// into (card ~380px, fullscreen column ~442px) sits just under the component's @md breakpoint,
// so "auto" always fell back to the mobile accordion that buries every column but the first.
Component: lazy(() => import('./components/data-table').then((m) => ({ default: m.DataTable.Table }))),
load: () => import('./components/data-table').then((m) => m.DataTable.Table),
loadSchema: () => import('./components/data-table/schema').then((m) => m.SerializableDataTableSchema),
},
'image': {
Component: lazy(() => import('./components/image').then((m) => ({ default: m.Image }))),
load: () => import('./components/image').then((m) => m.Image),
loadSchema: () => import('./components/image/schema').then((m) => m.SerializableImageSchema),
},
'image-gallery': {
Component: lazy(() => import('./components/image-gallery').then((m) => ({ default: m.ImageGallery }))),
load: () => import('./components/image-gallery').then((m) => m.ImageGallery),
loadSchema: () => import('./components/image-gallery/schema').then((m) => m.SerializableImageGallerySchema),
},
'instagram-post': {
Component: lazy(() => import('./components/instagram-post').then((m) => ({ default: wrapAsPost(m.InstagramPost) }))),
load: () => import('./components/instagram-post').then((m) => wrapAsPost(m.InstagramPost)),
loadSchema: () => import('./components/instagram-post/schema').then((m) => m.SerializableInstagramPostSchema),
},
'item-carousel': {
Component: lazy(() => import('./components/item-carousel').then((m) => ({ default: m.ItemCarousel }))),
load: () => import('./components/item-carousel').then((m) => m.ItemCarousel),
loadSchema: () => import('./components/item-carousel/schema').then((m) => m.SerializableItemCarouselSchema),
},
'link-preview': {
Component: lazy(() => import('./components/link-preview').then((m) => ({ default: m.LinkPreview }))),
load: () => import('./components/link-preview').then((m) => m.LinkPreview),
loadSchema: () => import('./components/link-preview/schema').then((m) => m.SerializableLinkPreviewSchema),
},
'linkedin-post': {
Component: lazy(() => import('./components/linkedin-post').then((m) => ({ default: wrapAsPost(m.LinkedInPost) }))),
load: () => import('./components/linkedin-post').then((m) => wrapAsPost(m.LinkedInPost)),
loadSchema: () => import('./components/linkedin-post/schema').then((m) => m.SerializableLinkedInPostSchema),
},
'message-draft': {
Component: lazy(() => import('./components/message-draft').then((m) => ({ default: m.MessageDraft }))),
load: () => import('./components/message-draft').then((m) => m.MessageDraft),
loadSchema: () => import('./components/message-draft/schema').then((m) => m.SerializableEmailDraftSchema),
},
'option-list': {
Component: lazy(() => import('./components/option-list').then((m) => ({ default: m.OptionList }))),
load: () => import('./components/option-list').then((m) => m.OptionList),
loadSchema: () => import('./components/option-list/schema').then((m) => m.SerializableOptionListSchema),
},
'order-summary': {
Component: lazy(() => import('./components/order-summary').then((m) => ({ default: m.OrderSummary }))),
load: () => import('./components/order-summary').then((m) => m.OrderSummary),
loadSchema: () => import('./components/order-summary/schema').then((m) => m.SerializableOrderSummarySchema),
},
'parameter-slider': {
Component: lazy(() => import('./components/parameter-slider').then((m) => ({ default: m.ParameterSlider }))),
load: () => import('./components/parameter-slider').then((m) => m.ParameterSlider),
loadSchema: () => import('./components/parameter-slider/schema').then((m) => m.SerializableParameterSliderSchema),
},
'plan': {
Component: lazy(() => import('./components/plan').then((m) => ({ default: m.Plan }))),
load: () => import('./components/plan').then((m) => m.Plan),
loadSchema: () => import('./components/plan/schema').then((m) => m.SerializablePlanSchema),
},
'preferences-panel': {
Component: lazy(() => import('./components/preferences-panel').then((m) => ({ default: m.PreferencesPanel }))),
load: () => import('./components/preferences-panel').then((m) => m.PreferencesPanel),
loadSchema: () => import('./components/preferences-panel/schema').then((m) => m.SerializablePreferencesPanelSchema),
},
'progress-tracker': {
Component: lazy(() => import('./components/progress-tracker').then((m) => ({ default: m.ProgressTracker }))),
load: () => import('./components/progress-tracker').then((m) => m.ProgressTracker),
loadSchema: () => import('./components/progress-tracker/schema').then((m) => m.SerializableProgressTrackerSchema),
},
'question-flow': {
Component: lazy(() => import('./components/question-flow').then((m) => ({ default: m.QuestionFlow }))),
load: () => import('./components/question-flow').then((m) => m.QuestionFlow),
// The full union: progressive (step/title), upfront (steps[]), and receipt modes are all valid wire shapes.
loadSchema: () => import('./components/question-flow/schema').then((m) => m.SerializableQuestionFlowSchema),
},
'stats-display': {
Component: lazy(() => import('./components/stats-display').then((m) => ({ default: m.StatsDisplay }))),
load: () => import('./components/stats-display').then((m) => m.StatsDisplay),
loadSchema: () => import('./components/stats-display/schema').then((m) => m.SerializableStatsDisplaySchema),
},
'terminal': {
Component: lazy(() => import('./components/terminal').then((m) => ({ default: m.Terminal }))),
load: () => import('./components/terminal').then((m) => m.Terminal),
loadSchema: () => import('./components/terminal/schema').then((m) => m.SerializableTerminalSchema),
},
'video': {
Component: lazy(() => import('./components/video').then((m) => ({ default: m.Video }))),
load: () => import('./components/video').then((m) => m.Video),
loadSchema: () => import('./components/video/schema').then((m) => m.SerializableVideoSchema),
},
'x-post': {
Component: lazy(() => import('./components/x-post').then((m) => ({ default: wrapAsPost(m.XPost) }))),
load: () => import('./components/x-post').then((m) => wrapAsPost(m.XPost)),
loadSchema: () => import('./components/x-post/schema').then((m) => m.SerializableXPostSchema),
},
};
@@ -0,0 +1,25 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
// 2026-09-05: with the ShowUI bubble memoized on message identity, a data-table rendered under React.lazy + Suspense
// stayed a skeleton forever: the chunk had loaded and the lazy had resolved, but the boundary's retry never ran, and the
// only thing that drew the table was a later prop change through the memo. A lazily loaded widget must own its readiness.
test('the vendored loader delivers the component through state, never through Suspense', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src/toolui/VendoredToolUi.tsx'), 'utf8');
assert.ok(!/\bSuspense\b/.test(src), 'no Suspense boundary in the vendored path');
assert.ok(!/\blazy\(/.test(src), 'no React.lazy in the vendored path');
assert.ok(src.includes('setComponent(() => Loaded)'), 'the loaded component lands in state');
assert.ok(src.includes("if (gate.state === 'pending' || !Component)"), 'the skeleton stays until BOTH the schema and the component are here');
});
test('every registry entry exposes a plain loader and none is a React.lazy', () => {
const src = fs.readFileSync(path.join(process.cwd(), 'src/toolui/registry.tsx'), 'utf8');
assert.ok(!/\blazy\(/.test(src));
const entries = (src.match(/^\s+'[a-z-]+': \{/gm) || []).length;
const loaders = (src.match(/^\s+load: \(\) => import\(/gm) || []).length;
assert.equal(entries, 26);
assert.equal(loaders, entries, 'one load() per entry');
});