fix(items): retry failed model loads, settle skipped items, keep exports clean (#480)
* fix(items): retry failed model loads, settle skipped items, keep exports clean A transient storage failure (observed: Supabase 504s under the bake page's ~200-concurrent-request burst) permanently broke an item for the whole session: drei's useGLTF caches the rejected load by URL, the per-item ErrorBoundary swallowed it, and the red debug-box fallback was BAKED into the exported GLB (observed in a prod artifact). Meanwhile ItemSystem cleared the dirty mark at group registration — before the model resolved — so scene-ready could fire while GLBs were still loading, risking exported placeholder geometry. - ModelWithRetry: bounded retries (1s/3s) that clear the useGLTF cache entry and re-mount via the boundary's new resetKey. The timer is owned by an effect keyed on the failure count, so StrictMode's synthetic unmount/remount re-arms it instead of silently discarding it (the naive onError-scheduled timer died exactly that way in dev). - Exhausted retries settle the item as SKIPPED: the debug box renders nothing during exports, and the failure lands in useViewer.itemLoadFailures (nodeId -> url) so a bake host can persist which items are missing from the artifact. - ItemSystem holds the dirty mark until the item settles (model mounted, terminally failed, or never expected) — scene-ready now genuinely waits for item content; loading placeholders also hide during exports. - ErrorBoundary: onError + resetKey props. Verified against a 200+-item prod scene locally: permanent 504 -> exactly 3 fetch attempts, bake completes without the item and without debug boxes; 504-once -> retry heals, artifact byte-equivalent to the intact run; no-failure runs unchanged (demo_1 byte-identical). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(items): reset retry budget + settled flag when the asset URL changes Bugbot: after a terminal load failure, swapping the item's model kept the stale failures/epoch state and the settled flag — the new URL never even attempted to load. ModelWithRetry is now keyed by asset src (clean retry budget per URL) and un-settles the item on mount so the replacement load is awaited by ItemSystem/scene-ready too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5831b7b234
commit
feeabf4bd3
@@ -6,6 +6,11 @@ interface ErrorBoundaryProps {
|
||||
fallback: ReactNode
|
||||
/** Tag for log lines so we can tell which boundary swallowed an error. */
|
||||
scope?: string
|
||||
/** Notified once per caught error — lets the host schedule a retry. */
|
||||
onError?: (error: Error) => void
|
||||
/** Changing this key clears a caught error and re-mounts `children` — the
|
||||
* retry half of `onError` (bump it after clearing whatever failed). */
|
||||
resetKey?: unknown
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boolean }> {
|
||||
@@ -19,6 +24,12 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boo
|
||||
error,
|
||||
info.componentStack,
|
||||
)
|
||||
this.props.onError?.(error)
|
||||
}
|
||||
componentDidUpdate(prevProps: ErrorBoundaryProps) {
|
||||
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
|
||||
this.setState({ hasError: false })
|
||||
}
|
||||
}
|
||||
render() {
|
||||
return this.state.hasError ? this.props.fallback : this.props.children
|
||||
|
||||
@@ -49,6 +49,14 @@ type ViewerState = {
|
||||
isExporting: boolean
|
||||
setExporting: (value: boolean) => void
|
||||
|
||||
/** Item model loads that exhausted their retries — nodeId → asset URL. The
|
||||
* scene renders without these items (they settle as skipped); a bake host
|
||||
* can persist the map onto the artifact's metadata so a missing item is
|
||||
* queryable instead of silently absent. Transient (never persisted). */
|
||||
itemLoadFailures: Record<string, string>
|
||||
reportItemLoadFailure: (nodeId: string, url: string) => void
|
||||
clearItemLoadFailure: (nodeId: string) => void
|
||||
|
||||
/** Suspend the render loop while the canvas is fully covered (e.g. studio gallery). */
|
||||
renderPaused: boolean
|
||||
setRenderPaused: (value: boolean) => void
|
||||
@@ -245,6 +253,21 @@ const useViewer = create<ViewerState>()(
|
||||
isExporting: false,
|
||||
setExporting: (value) => set({ isExporting: value }),
|
||||
|
||||
itemLoadFailures: {},
|
||||
reportItemLoadFailure: (nodeId, url) =>
|
||||
set((state) =>
|
||||
state.itemLoadFailures[nodeId] === url
|
||||
? state
|
||||
: { itemLoadFailures: { ...state.itemLoadFailures, [nodeId]: url } },
|
||||
),
|
||||
clearItemLoadFailure: (nodeId) =>
|
||||
set((state) => {
|
||||
if (!(nodeId in state.itemLoadFailures)) return state
|
||||
const next = { ...state.itemLoadFailures }
|
||||
delete next[nodeId]
|
||||
return { itemLoadFailures: next }
|
||||
}),
|
||||
|
||||
renderPaused: false,
|
||||
setRenderPaused: (value) => set({ renderPaused: value }),
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ export const ItemSystem = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Hold the mark until the model settles (loaded, terminally failed, or
|
||||
// never expected — see ItemRenderer.markSettled). Registration alone
|
||||
// isn't "built": clearing then would let scene-ready fire while GLBs are
|
||||
// still downloading and a bake would export loading placeholders.
|
||||
const settled = (mesh.userData as { itemModelSettled?: boolean }).itemModelSettled
|
||||
if (!settled) return
|
||||
|
||||
clearDirty(id as AnyNodeId)
|
||||
})
|
||||
}, 2)
|
||||
|
||||
Reference in New Issue
Block a user