Skip to content

Commit 33cbb41

Browse files
fix(react-core): cross-over the A2UI skeleton into the painted surface (OSS-162)
The skeleton unmounted the instant a2ui_operations arrived, but the A2UIProvider needs a couple ticks to process the ops and paint — leaving a visible empty gap between skeleton and first card. Hold the loader in-flow while the surface mounts and paints OFFSCREEN (absolute, opacity 0), then swap, so the first card truly replaces the skeleton. Keep showing the last pre-paint snapshot during the hand-off so the building count / retry status carries through without a flicker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6d24d63 commit 33cbb41

1 file changed

Lines changed: 57 additions & 16 deletions

File tree

packages/react-core/src/v2/a2ui/A2UIMessageRenderer.tsx

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -125,27 +125,21 @@ export function createA2UIMessageRenderer(
125125
return groups;
126126
}, [operations]);
127127

128-
if (!groupedOperations.size) {
129-
// No painted surface yet → render the pre-paint lifecycle state. These
130-
// share this activity's messageId, so the painted surface below replaces
131-
// them in place once operations arrive.
132-
const status = content?.status;
133-
const debugExposure = resolveDebugExposure(
134-
content,
135-
optionDebugExposure,
136-
);
128+
const hasOps = groupedOperations.size > 0;
129+
130+
// Renders the pre-paint lifecycle state for a given content snapshot.
131+
const renderLifecycle = (c: any) => {
132+
const status = c?.status;
133+
const debugExposure = resolveDebugExposure(c, optionDebugExposure);
137134
if (status === "failed") {
138135
return (
139-
<A2UIRecoveryFailure
140-
content={content}
141-
debugExposure={debugExposure}
142-
/>
136+
<A2UIRecoveryFailure content={c} debugExposure={debugExposure} />
143137
);
144138
}
145139
if (status === "retrying") {
146140
return (
147141
<A2UIRetryingState
148-
content={content}
142+
content={c}
149143
showAfterMs={showAfterMs}
150144
showAfterAttempts={showAfterAttempts}
151145
debugExposure={debugExposure}
@@ -157,10 +151,37 @@ export function createA2UIMessageRenderer(
157151
const LoadingComponent = loadingComponent;
158152
return <LoadingComponent />;
159153
}
160-
return <A2UIBuildingState content={content} />;
154+
return <A2UIBuildingState content={c} />;
155+
};
156+
157+
// Remember the last pre-paint snapshot so the hand-off below keeps showing
158+
// exactly what was on screen (building skeleton w/ its count, or the retry
159+
// status) instead of flickering to a generic one.
160+
const lastLoaderContentRef = useRef<any>(null);
161+
if (!hasOps) lastLoaderContentRef.current = content;
162+
163+
// Cross-over: when operations first arrive, the A2UIProvider needs a couple
164+
// ticks to process them and paint. Hold the loader in-flow (it defines the
165+
// height) while the surface paints OFFSCREEN, then swap — so the first card
166+
// REPLACES the skeleton with no empty gap. (OSS-162)
167+
const [surfaceReady, setSurfaceReady] = useState(false);
168+
useEffect(() => {
169+
if (!hasOps) {
170+
setSurfaceReady(false);
171+
return;
172+
}
173+
const t = setTimeout(() => setSurfaceReady(true), 220);
174+
return () => clearTimeout(t);
175+
}, [hasOps]);
176+
177+
if (!hasOps) {
178+
// No painted surface yet → render the pre-paint lifecycle state. These
179+
// share this activity's messageId, so the painted surface below replaces
180+
// them in place once operations arrive.
181+
return renderLifecycle(content);
161182
}
162183

163-
return (
184+
const surfaces = (
164185
<div className="cpk:flex cpk:min-h-0 cpk:flex-1 cpk:flex-col cpk:gap-6 cpk:overflow-auto cpk:py-6">
165186
{Array.from(groupedOperations.entries()).map(([surfaceId, ops]) => (
166187
<ReactSurfaceHost
@@ -175,6 +196,26 @@ export function createA2UIMessageRenderer(
175196
))}
176197
</div>
177198
);
199+
200+
if (surfaceReady) return surfaces;
201+
202+
// Surface mounts/paints offscreen behind the still-visible loader.
203+
return (
204+
<div style={{ position: "relative" }}>
205+
<div
206+
aria-hidden
207+
style={{
208+
position: "absolute",
209+
inset: 0,
210+
opacity: 0,
211+
pointerEvents: "none",
212+
}}
213+
>
214+
{surfaces}
215+
</div>
216+
{renderLifecycle(lastLoaderContentRef.current ?? content)}
217+
</div>
218+
);
178219
},
179220
};
180221
}

0 commit comments

Comments
 (0)