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:
Wassim SAMAD
2026-07-09 14:50:20 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 5831b7b234
commit feeabf4bd3
4 changed files with 139 additions and 7 deletions
@@ -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
+23
View File
@@ -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)