From 9ce20689b9212a2d135524d90151c2899e751a41 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 6 Sep 2026 12:31:01 -0700 Subject: [PATCH] [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 --- frontend/src/toolui/VendoredToolUi.tsx | 33 +++++++---- frontend/src/toolui/registry.tsx | 58 +++++++++---------- .../src/toolui/vendoredOwnsReadiness.test.ts | 25 ++++++++ 3 files changed, 76 insertions(+), 40 deletions(-) create mode 100644 frontend/src/toolui/vendoredOwnsReadiness.test.ts diff --git a/frontend/src/toolui/VendoredToolUi.tsx b/frontend/src/toolui/VendoredToolUi.tsx index eca181eb..b9f3ce3a 100644 --- a/frontend/src/toolui/VendoredToolUi.tsx +++ b/frontend/src/toolui/VendoredToolUi.tsx @@ -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(); +// 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>>(); +function componentFor(entry: ToolUiEntry): Promise> { + 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({ state: 'pending' }); + const [Component, setComponent] = useState | 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 ); } - if (gate.state === 'pending') { + if (gate.state === 'pending' || !Component) { return quietFail ? null : ; } - const Component = entry.Component; return (
- }> - - +
); diff --git a/frontend/src/toolui/registry.tsx b/frontend/src/toolui/registry.tsx index d168fc28..4280459e 100644 --- a/frontend/src/toolui/registry.tsx +++ b/frontend/src/toolui/registry.tsx @@ -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>; + load: () => Promise>; loadSchema: () => Promise>; } @@ -21,111 +21,111 @@ function wrapAsPost

(Inner: React.ComponentType<{ pos export const TOOL_UI_REGISTRY: Record = { '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), }, }; diff --git a/frontend/src/toolui/vendoredOwnsReadiness.test.ts b/frontend/src/toolui/vendoredOwnsReadiness.test.ts new file mode 100644 index 00000000..91696eba --- /dev/null +++ b/frontend/src/toolui/vendoredOwnsReadiness.test.ts @@ -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'); +});