mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 13:17:40 +02:00
[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:
co-authored by
Claude Fable 5.1
parent
548a55a6d2
commit
9ce20689b9
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user