From 8d3dc1dc106e00db82c84ac93561f041d43b116f Mon Sep 17 00:00:00 2001 From: Iwo Plaza Date: Fri, 22 May 2026 16:58:57 +0200 Subject: [PATCH] fix(@typegpu/react): Handle device loss gracefully --- .github/workflows/pkg-pr.yml | 2 +- .../react/spinning-triangle/index.tsx | 14 +++++-- package.json | 2 +- .../src/browser/use-configure-context.ts | 4 +- .../typegpu-react/src/core/root-context.tsx | 41 ++++++++++++++++++- 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pkg-pr.yml b/.github/workflows/pkg-pr.yml index b8cabf72c3..48e55487ae 100644 --- a/.github/workflows/pkg-pr.yml +++ b/.github/workflows/pkg-pr.yml @@ -37,7 +37,7 @@ jobs: run: pnpm nightly-build - name: Publish (pkg.pr.new) - run: pnpm exec pkg-pr-new publish './packages/typegpu' './packages/typegpu-noise' './packages/unplugin-typegpu' --json output.json --comment=off --pnpm --no-compact + run: pnpm exec pkg-pr-new publish './packages/typegpu' './packages/typegpu-noise' './packages/typegpu-react' './packages/unplugin-typegpu' --json output.json --comment=off --pnpm --no-compact - name: Post or update comment uses: actions/github-script@v6 with: diff --git a/apps/typegpu-docs/src/examples/react/spinning-triangle/index.tsx b/apps/typegpu-docs/src/examples/react/spinning-triangle/index.tsx index a1bcf3352e..35cc6e5a46 100644 --- a/apps/typegpu-docs/src/examples/react/spinning-triangle/index.tsx +++ b/apps/typegpu-docs/src/examples/react/spinning-triangle/index.tsx @@ -57,13 +57,21 @@ function App() { const { ref, ctxRef } = useConfigureContext({ alphaMode: 'premultiplied' }); useFrame(({ elapsedSeconds }) => { - if (!ctxRef.current) return; + const ctx = ctxRef.current; + if (!ctx) return; time.write(elapsedSeconds); - renderPipeline.withColorAttachment({ view: ctxRef.current }).draw(3); + renderPipeline.withColorAttachment({ view: ctx }).draw(3); }); - return ; + return ( + <> + + + + ); } // #region Example controls and cleanup diff --git a/package.json b/package.json index 9c4c629f92..bb183f91d8 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "test:browser": "vitest run --browser.enabled --project browser", "test:browser:watch": "vitest --browser.enabled --project browser", "test:coverage": "vitest --coverage run", - "nightly-build": "SKIP_TESTS=true pnpm --filter typegpu --filter @typegpu/noise --filter unplugin-typegpu prepublishOnly --skip-publish-tag-check", + "nightly-build": "SKIP_TESTS=true pnpm --filter typegpu --filter @typegpu/noise --filter @typegpu/react --filter unplugin-typegpu prepublishOnly --skip-publish-tag-check", "changes": "tgpu-dev-cli changes" }, "devDependencies": { diff --git a/packages/typegpu-react/src/browser/use-configure-context.ts b/packages/typegpu-react/src/browser/use-configure-context.ts index 039f8d9e7e..0c04949af9 100644 --- a/packages/typegpu-react/src/browser/use-configure-context.ts +++ b/packages/typegpu-react/src/browser/use-configure-context.ts @@ -29,8 +29,8 @@ const useResizer: UseResizerHook = () => { return; } - el.width = Math.round(box.inlineSize * dpr); - el.height = Math.round(box.blockSize * dpr); + el.width = Math.max(1, Math.round(box.inlineSize * dpr)); + el.height = Math.max(1, Math.round(box.blockSize * dpr)); }); const attachResizing = useEffectEvent((el: HTMLCanvasElement | OffscreenCanvas | null) => { diff --git a/packages/typegpu-react/src/core/root-context.tsx b/packages/typegpu-react/src/core/root-context.tsx index 0c27b6ab4d..900084aa74 100644 --- a/packages/typegpu-react/src/core/root-context.tsx +++ b/packages/typegpu-react/src/core/root-context.tsx @@ -4,6 +4,7 @@ import React, { useContext, useEffect, useMemo, + useRef, useState, } from 'react'; import tgpu, { type TgpuRoot } from 'typegpu'; @@ -58,6 +59,7 @@ type RootContextResult = | { status: 'rejected'; error: unknown }; interface RootContext { + readonly rootRequested: boolean; initOrGetRoot(): RootContextResult; } @@ -71,6 +73,10 @@ class OwnRootContext implements RootContext { promise: tgpu.init().then( (root) => { this.#result = { status: 'resolved', value: root }; + root.device.lost.then(() => { + // TODO: React to reason + this.#result = undefined; + }); return root; }, (error) => { @@ -83,6 +89,10 @@ class OwnRootContext implements RootContext { return this.#result; } + + get rootRequested() { + return this.#result !== undefined; + } } class ExistingRootContext implements RootContext { @@ -95,6 +105,10 @@ class ExistingRootContext implements RootContext { initOrGetRoot(): RootContextResult { return this.result; } + + get rootRequested() { + return this.result !== undefined; + } } /** @@ -144,7 +158,32 @@ export function useRoot(): TgpuRoot { if (result.status === 'rejected') { throw result.error as Error; } - return result.status === 'pending' ? use(result.promise) : result.value; + const root = result.status === 'pending' ? use(result.promise) : result.value; + + // NOTE: Useful docs: https://toji.dev/webgpu-best-practices/device-loss.html + const [_, rerender] = useState(0); + const lostRootsRef = useRef>(new WeakSet()); + useEffect(() => { + let cancelled = false; + + root.device.lost.then(() => { + // TODO: React to reason + if (cancelled || lostRootsRef.current.has(root)) { + return; + } + lostRootsRef.current.add(root); + + if (!context.rootRequested) { + rerender((a) => a + 1); + } + }); + + return () => { + cancelled = true; + }; + }, [root]); + + return root; } /**