fix: WebGPU renderer race on Canvas remount & duplicate item scale loss (#284)

Backport from monorepo PR #310 (fix/april-pass):

1. WebGPU renderer race on Canvas remount
   Cache the in-flight WebGPURenderer promise per canvas element so
   concurrent configure() calls from R3F's useLayoutEffect await the
   same init() instead of creating duplicate renderers. Fixes
   intermittent 'resolve target size does not match' errors when
   navigating between projects and home.

2. Duplicate item loses its scale
   Add scale to the field set in useDraftNode.commit() so duplicated
   items preserve their original scale instead of falling back to
   [1,1,1].

Co-authored-by: Pascal <open@pascal.app>
This commit is contained in:
Aymeric Rabot
2026-04-28 12:42:08 -04:00
committed by GitHub
co-authored by Pascal
parent e615815e65
commit 2b588cd30f
2 changed files with 47 additions and 22 deletions
@@ -130,6 +130,7 @@ export function useDraftNode(): DraftNodeHandle {
useScene.getState().updateNode(draft.id, { useScene.getState().updateNode(draft.id, {
position: updateProps.position ?? draft.position, position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation, rotation: updateProps.rotation ?? draft.rotation,
scale: updateProps.scale ?? draft.scale,
side: updateProps.side ?? draft.side, side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata), metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string, parentId: parentId as string,
@@ -161,6 +162,7 @@ export function useDraftNode(): DraftNodeHandle {
asset: draft.asset, asset: draft.asset,
position: updateProps.position ?? draft.position, position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation, rotation: updateProps.rotation ?? draft.rotation,
scale: updateProps.scale ?? draft.scale,
side: updateProps.side ?? draft.side, side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata), metadata: updateProps.metadata ?? stripTransient(draft.metadata),
}) })
+45 -22
View File
@@ -65,6 +65,22 @@ declare module '@react-three/fiber' {
extend(THREE as any) extend(THREE as any)
// R3F's <Canvas> useLayoutEffect has no deps, so any re-render (theme switch,
// parent re-render, StrictMode double-mount) re-invokes `configure()`. With a
// sync `gl` factory that's harmless — the renderer is created once and reused.
// With an async factory (WebGPURenderer needs `await init()`), two configure
// calls can race: both see `state.gl == null` and both create a renderer. The
// first to resolve gets `setSize`/`setDpr` called on it; the second overwrites
// `state.gl` but R3F's store already holds the new size/dpr, so the new
// renderer is never resized and stays at the canvas's 300×150 default.
//
// Caching by canvas guarantees both branches return the same instance, so
// "duplicate" configure calls become no-ops on an already-sized renderer.
// We cache the in-flight Promise (not just the resolved renderer) so two
// concurrent configure() calls await the same init instead of creating two
// renderers in parallel and only caching the second.
const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
/** /**
* Monitors the WebGPU device for loss events and logs them. * Monitors the WebGPU device for loss events and logs them.
* WebGPU device loss can happen when: * WebGPU device loss can happen when:
@@ -147,8 +163,11 @@ const Viewer: React.FC<ViewerProps> = ({
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`} className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, 1.5]} dpr={[1, 1.5]}
frameloop="never" frameloop="never"
gl={async (props) => { gl={
try { ((props: { canvas?: HTMLCanvasElement }) => {
const canvas = props.canvas
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
if (cached) return cached
// Surface the env we're about to ask WebGPU for — catches "no // Surface the env we're about to ask WebGPU for — catches "no
// navigator.gpu" / "adapter request failed" silently failing in // navigator.gpu" / "adapter request failed" silently failing in
// mobile WebViews where WebGPU is gated behind flags. // mobile WebViews where WebGPU is gated behind flags.
@@ -157,26 +176,30 @@ const Viewer: React.FC<ViewerProps> = ({
hasNavigatorGPU: hasGpu, hasNavigatorGPU: hasGpu,
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a', ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
}) })
const renderer = new THREE.WebGPURenderer(props as any) const promise = (async () => {
renderer.toneMapping = THREE.ACESFilmicToneMapping try {
renderer.toneMappingExposure = 0.9 const renderer = new THREE.WebGPURenderer(props as any)
// Awaiting init() is required when the browser falls back to the renderer.toneMapping = THREE.ACESFilmicToneMapping
// WebGL2 backend (Safari without the WebGPU flag, older Chrome on renderer.toneMappingExposure = 0.9
// machines without a WebGPU device). In native WebGPU mode the await renderer.init()
// init resolves almost instantly. Without this await, the first console.log('[viewer] WebGPURenderer ready', {
// render throws "Renderer: .render() called before the backend is backend: (renderer as any).backend?.constructor?.name,
// initialized" from the post-processing fallback path. isWebGPU: (renderer as any).isWebGPURenderer === true,
await renderer.init() })
console.log('[viewer] WebGPURenderer ready', { return renderer
backend: (renderer as any).backend?.constructor?.name, } catch (err) {
isWebGPU: (renderer as any).isWebGPURenderer === true, // Drop the failed promise from the cache so a future Canvas
}) // mount on the same DOM can retry instead of inheriting the
return renderer // rejection forever.
} catch (err) { if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas)
console.error('[viewer] WebGPURenderer init failed', err) console.error('[viewer] WebGPURenderer init failed', err)
throw err throw err
} }
}} })()
if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise)
return promise
}) as any
}
resize={{ resize={{
debounce: 100, debounce: 100,
}} }}