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
@@ -36,7 +36,7 @@ import { useAnimations } from '@react-three/drei'
|
|||||||
import { Clone } from '@react-three/drei/core/Clone'
|
import { Clone } from '@react-three/drei/core/Clone'
|
||||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { Suspense, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
||||||
import { MathUtils } from 'three'
|
import { MathUtils } from 'three'
|
||||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||||
@@ -181,6 +181,7 @@ const resolveItemMaterial = (
|
|||||||
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||||
const handlers = useNodeEvents(node, 'item')
|
const handlers = useNodeEvents(node, 'item')
|
||||||
const shading = useViewer((s) => s.shading)
|
const shading = useViewer((s) => s.shading)
|
||||||
|
const isExporting = useViewer((s) => s.isExporting)
|
||||||
const [w, h, d] = node.asset.dimensions
|
const [w, h, d] = node.asset.dimensions
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const next = createDefaultMaterial('#ef4444', 1, shading) as MutableMaterial
|
const next = createDefaultMaterial('#ef4444', 1, shading) as MutableMaterial
|
||||||
@@ -191,6 +192,10 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
|||||||
return next
|
return next
|
||||||
}, [shading])
|
}, [shading])
|
||||||
|
|
||||||
|
// Debug affordance only — a bake must never ship the red placeholder box
|
||||||
|
// (observed baked into a prod artifact when an item GLB 504'd mid-capture).
|
||||||
|
if (isExporting) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh position-y={h / 2} {...handlers}>
|
<mesh position-y={h / 2} {...handlers}>
|
||||||
<boxGeometry args={[w, h, d]} />
|
<boxGeometry args={[w, h, d]} />
|
||||||
@@ -199,11 +204,88 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MODEL_RETRY_DELAYS_MS = [1_000, 3_000]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the item model with bounded retries. drei's `useGLTF` caches a rejected
|
||||||
|
* load by URL, so a transient fetch failure (e.g. a storage 504 under the bake
|
||||||
|
* page's asset-request burst) would otherwise stay broken for the whole
|
||||||
|
* session — clear the cache entry and re-mount. After the retries are
|
||||||
|
* exhausted the item settles as SKIPPED: it renders the debug box (nothing
|
||||||
|
* during exports) and lands in `useViewer.itemLoadFailures` so a bake host can
|
||||||
|
* record which items are missing from the artifact.
|
||||||
|
*/
|
||||||
|
const ModelWithRetry = ({
|
||||||
|
node,
|
||||||
|
setSettled,
|
||||||
|
}: {
|
||||||
|
node: ItemNode
|
||||||
|
setSettled: (value: boolean) => void
|
||||||
|
}) => {
|
||||||
|
// `failures` counts boundary catches; `epoch` bumps after each cache clear
|
||||||
|
// to reset the boundary and re-mount the loader. The retry timer is owned by
|
||||||
|
// an effect (not the error handler) so StrictMode's synthetic
|
||||||
|
// unmount/remount re-arms it instead of silently discarding it. The host
|
||||||
|
// keys this component by asset URL, so a model swap starts from a clean
|
||||||
|
// retry budget — and the mount effect below un-settles the item so the new
|
||||||
|
// load is awaited too.
|
||||||
|
const [failures, setFailures] = useState(0)
|
||||||
|
const [epoch, setEpoch] = useState(0)
|
||||||
|
const url = resolveCdnUrl(node.asset.src) || ''
|
||||||
|
const gaveUp = !url || failures > MODEL_RETRY_DELAYS_MS.length
|
||||||
|
|
||||||
|
const handleError = useCallback(() => setFailures((current) => current + 1), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSettled(false)
|
||||||
|
}, [setSettled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (failures === 0 || gaveUp) return
|
||||||
|
const delay = MODEL_RETRY_DELAYS_MS[failures - 1] ?? 0
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log(`[item] retrying model load (${failures}/${MODEL_RETRY_DELAYS_MS.length}) ${url}`)
|
||||||
|
useGLTF.clear(url)
|
||||||
|
setEpoch((current) => current + 1)
|
||||||
|
}, delay)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [failures, gaveUp, url])
|
||||||
|
|
||||||
|
const markSettled = useCallback(() => setSettled(true), [setSettled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gaveUp) return
|
||||||
|
markSettled()
|
||||||
|
useViewer.getState().reportItemLoadFailure(node.id, url)
|
||||||
|
return () => useViewer.getState().clearItemLoadFailure(node.id)
|
||||||
|
}, [gaveUp, markSettled, node.id, url])
|
||||||
|
|
||||||
|
if (gaveUp) return <BrokenItemFallback node={node} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary fallback={<PreviewModel node={node} />} onError={handleError} resetKey={epoch}>
|
||||||
|
<Suspense fallback={<PreviewModel node={node} />}>
|
||||||
|
<ModelRenderer markSettled={markSettled} node={node} />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
|
export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
|
||||||
const ref = useRef<Group>(null!)
|
const ref = useRef<Group>(null!)
|
||||||
|
|
||||||
useRegistry(storeNode.id, storeNode.type, ref)
|
useRegistry(storeNode.id, storeNode.type, ref)
|
||||||
|
|
||||||
|
// "Settled" = the model resolved, terminally failed (skipped), or was never
|
||||||
|
// expected. `ItemSystem` holds the dirty mark until then, so scene-ready
|
||||||
|
// (and headless bakes) wait for real item content instead of exporting the
|
||||||
|
// loading placeholder. A model swap un-settles (ModelWithRetry's mount
|
||||||
|
// effect via its URL key) so the replacement load is awaited too.
|
||||||
|
const setSettled = useCallback((value: boolean) => {
|
||||||
|
const group = ref.current as (Group & { userData: Record<string, unknown> }) | null
|
||||||
|
if (group) group.userData.itemModelSettled = value
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Merge live drag overrides so the mesh transforms in real time during a
|
// Merge live drag overrides so the mesh transforms in real time during a
|
||||||
// drag (e.g. the in-world rotate gizmo). The handle writes the in-flight
|
// drag (e.g. the in-world rotate gizmo). The handle writes the in-flight
|
||||||
// rotation to `useLiveNodeOverrides` on every pointer move and commits to
|
// rotation to `useLiveNodeOverrides` on every pointer move and commits to
|
||||||
@@ -217,17 +299,17 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
|
|||||||
const roomClearPreview =
|
const roomClearPreview =
|
||||||
(node as ItemNode & { roomClearPreview?: unknown }).roomClearPreview === true
|
(node as ItemNode & { roomClearPreview?: unknown }).roomClearPreview === true
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (roomClearPreview) setSettled(true)
|
||||||
|
}, [roomClearPreview, setSettled])
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
|
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
|
||||||
{roomClearPreview ? (
|
{roomClearPreview ? (
|
||||||
<ClearPreviewModel node={node} />
|
<ClearPreviewModel node={node} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ErrorBoundary fallback={<BrokenItemFallback node={node} />}>
|
<ModelWithRetry key={node.asset.src ?? 'no-src'} node={node} setSettled={setSettled} />
|
||||||
<Suspense fallback={<PreviewModel node={node} />}>
|
|
||||||
<ModelRenderer node={node} />
|
|
||||||
</Suspense>
|
|
||||||
</ErrorBoundary>
|
|
||||||
{node.children?.map((childId) => (
|
{node.children?.map((childId) => (
|
||||||
<NodeRenderer key={childId} nodeId={childId} />
|
<NodeRenderer key={childId} nodeId={childId} />
|
||||||
))}
|
))}
|
||||||
@@ -262,6 +344,9 @@ function getPreviewMaterial(shading: RenderShading): Material {
|
|||||||
|
|
||||||
const PreviewModel = ({ node }: { node: ItemNode }) => {
|
const PreviewModel = ({ node }: { node: ItemNode }) => {
|
||||||
const shading = useViewer((s) => s.shading)
|
const shading = useViewer((s) => s.shading)
|
||||||
|
const isExporting = useViewer((s) => s.isExporting)
|
||||||
|
// Loading placeholder — must never land in an exported GLB.
|
||||||
|
if (isExporting) return null
|
||||||
return (
|
return (
|
||||||
<mesh material={getPreviewMaterial(shading)} position-y={node.asset.dimensions[1] / 2}>
|
<mesh material={getPreviewMaterial(shading)} position-y={node.asset.dimensions[1] / 2}>
|
||||||
<boxGeometry
|
<boxGeometry
|
||||||
@@ -296,10 +381,16 @@ const multiplyScales = (
|
|||||||
b: [number, number, number],
|
b: [number, number, number],
|
||||||
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
||||||
|
|
||||||
const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled?: () => void }) => {
|
||||||
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||||
const ref = useRef<Group>(null!)
|
const ref = useRef<Group>(null!)
|
||||||
const { actions } = useAnimations(animations, ref)
|
const { actions } = useAnimations(animations, ref)
|
||||||
|
|
||||||
|
// Mounting past the suspense gate means the GLB resolved — the item's build
|
||||||
|
// work is done (`ItemSystem` may clear its dirty mark, scene-ready may fire).
|
||||||
|
useEffect(() => {
|
||||||
|
markSettled?.()
|
||||||
|
}, [markSettled])
|
||||||
const shading = useViewer((s) => s.shading)
|
const shading = useViewer((s) => s.shading)
|
||||||
const textures = useViewer((s) => s.textures)
|
const textures = useViewer((s) => s.textures)
|
||||||
const colorPreset = useViewer((s) => s.colorPreset)
|
const colorPreset = useViewer((s) => s.colorPreset)
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ interface ErrorBoundaryProps {
|
|||||||
fallback: ReactNode
|
fallback: ReactNode
|
||||||
/** Tag for log lines so we can tell which boundary swallowed an error. */
|
/** Tag for log lines so we can tell which boundary swallowed an error. */
|
||||||
scope?: string
|
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 }> {
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boolean }> {
|
||||||
@@ -19,6 +24,12 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boo
|
|||||||
error,
|
error,
|
||||||
info.componentStack,
|
info.componentStack,
|
||||||
)
|
)
|
||||||
|
this.props.onError?.(error)
|
||||||
|
}
|
||||||
|
componentDidUpdate(prevProps: ErrorBoundaryProps) {
|
||||||
|
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
|
||||||
|
this.setState({ hasError: false })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
render() {
|
render() {
|
||||||
return this.state.hasError ? this.props.fallback : this.props.children
|
return this.state.hasError ? this.props.fallback : this.props.children
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ type ViewerState = {
|
|||||||
isExporting: boolean
|
isExporting: boolean
|
||||||
setExporting: (value: boolean) => void
|
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). */
|
/** Suspend the render loop while the canvas is fully covered (e.g. studio gallery). */
|
||||||
renderPaused: boolean
|
renderPaused: boolean
|
||||||
setRenderPaused: (value: boolean) => void
|
setRenderPaused: (value: boolean) => void
|
||||||
@@ -245,6 +253,21 @@ const useViewer = create<ViewerState>()(
|
|||||||
isExporting: false,
|
isExporting: false,
|
||||||
setExporting: (value) => set({ isExporting: value }),
|
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,
|
renderPaused: false,
|
||||||
setRenderPaused: (value) => set({ renderPaused: value }),
|
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)
|
clearDirty(id as AnyNodeId)
|
||||||
})
|
})
|
||||||
}, 2)
|
}, 2)
|
||||||
|
|||||||
Reference in New Issue
Block a user