Skip to content

Commit 8d3dc1d

Browse files
committed
fix(@typegpu/react): Handle device loss gracefully
1 parent ebb4389 commit 8d3dc1d

5 files changed

Lines changed: 55 additions & 8 deletions

File tree

.github/workflows/pkg-pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
run: pnpm nightly-build
3838

3939
- name: Publish (pkg.pr.new)
40-
run: pnpm exec pkg-pr-new publish './packages/typegpu' './packages/typegpu-noise' './packages/unplugin-typegpu' --json output.json --comment=off --pnpm --no-compact
40+
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
4141
- name: Post or update comment
4242
uses: actions/github-script@v6
4343
with:

apps/typegpu-docs/src/examples/react/spinning-triangle/index.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,21 @@ function App() {
5757

5858
const { ref, ctxRef } = useConfigureContext({ alphaMode: 'premultiplied' });
5959
useFrame(({ elapsedSeconds }) => {
60-
if (!ctxRef.current) return;
60+
const ctx = ctxRef.current;
61+
if (!ctx) return;
6162

6263
time.write(elapsedSeconds);
63-
renderPipeline.withColorAttachment({ view: ctxRef.current }).draw(3);
64+
renderPipeline.withColorAttachment({ view: ctx }).draw(3);
6465
});
6566

66-
return <canvas ref={ref} className="aspect-square h-full max-h-[100vw]" />;
67+
return (
68+
<>
69+
<canvas ref={ref} className="aspect-square h-full max-h-[100vw]" />
70+
<button type="button" onClick={() => root.destroy()}>
71+
Destroy
72+
</button>
73+
</>
74+
);
6775
}
6876

6977
// #region Example controls and cleanup

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"test:browser": "vitest run --browser.enabled --project browser",
3333
"test:browser:watch": "vitest --browser.enabled --project browser",
3434
"test:coverage": "vitest --coverage run",
35-
"nightly-build": "SKIP_TESTS=true pnpm --filter typegpu --filter @typegpu/noise --filter unplugin-typegpu prepublishOnly --skip-publish-tag-check",
35+
"nightly-build": "SKIP_TESTS=true pnpm --filter typegpu --filter @typegpu/noise --filter @typegpu/react --filter unplugin-typegpu prepublishOnly --skip-publish-tag-check",
3636
"changes": "tgpu-dev-cli changes"
3737
},
3838
"devDependencies": {

packages/typegpu-react/src/browser/use-configure-context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ const useResizer: UseResizerHook = () => {
2929
return;
3030
}
3131

32-
el.width = Math.round(box.inlineSize * dpr);
33-
el.height = Math.round(box.blockSize * dpr);
32+
el.width = Math.max(1, Math.round(box.inlineSize * dpr));
33+
el.height = Math.max(1, Math.round(box.blockSize * dpr));
3434
});
3535

3636
const attachResizing = useEffectEvent((el: HTMLCanvasElement | OffscreenCanvas | null) => {

packages/typegpu-react/src/core/root-context.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React, {
44
useContext,
55
useEffect,
66
useMemo,
7+
useRef,
78
useState,
89
} from 'react';
910
import tgpu, { type TgpuRoot } from 'typegpu';
@@ -58,6 +59,7 @@ type RootContextResult =
5859
| { status: 'rejected'; error: unknown };
5960

6061
interface RootContext {
62+
readonly rootRequested: boolean;
6163
initOrGetRoot(): RootContextResult;
6264
}
6365

@@ -71,6 +73,10 @@ class OwnRootContext implements RootContext {
7173
promise: tgpu.init().then(
7274
(root) => {
7375
this.#result = { status: 'resolved', value: root };
76+
root.device.lost.then(() => {
77+
// TODO: React to reason
78+
this.#result = undefined;
79+
});
7480
return root;
7581
},
7682
(error) => {
@@ -83,6 +89,10 @@ class OwnRootContext implements RootContext {
8389

8490
return this.#result;
8591
}
92+
93+
get rootRequested() {
94+
return this.#result !== undefined;
95+
}
8696
}
8797

8898
class ExistingRootContext implements RootContext {
@@ -95,6 +105,10 @@ class ExistingRootContext implements RootContext {
95105
initOrGetRoot(): RootContextResult {
96106
return this.result;
97107
}
108+
109+
get rootRequested() {
110+
return this.result !== undefined;
111+
}
98112
}
99113

100114
/**
@@ -144,7 +158,32 @@ export function useRoot(): TgpuRoot {
144158
if (result.status === 'rejected') {
145159
throw result.error as Error;
146160
}
147-
return result.status === 'pending' ? use(result.promise) : result.value;
161+
const root = result.status === 'pending' ? use(result.promise) : result.value;
162+
163+
// NOTE: Useful docs: https://toji.dev/webgpu-best-practices/device-loss.html
164+
const [_, rerender] = useState(0);
165+
const lostRootsRef = useRef<WeakSet<TgpuRoot>>(new WeakSet());
166+
useEffect(() => {
167+
let cancelled = false;
168+
169+
root.device.lost.then(() => {
170+
// TODO: React to reason
171+
if (cancelled || lostRootsRef.current.has(root)) {
172+
return;
173+
}
174+
lostRootsRef.current.add(root);
175+
176+
if (!context.rootRequested) {
177+
rerender((a) => a + 1);
178+
}
179+
});
180+
181+
return () => {
182+
cancelled = true;
183+
};
184+
}, [root]);
185+
186+
return root;
148187
}
149188

150189
/**

0 commit comments

Comments
 (0)