diff --git a/packages/fiber/src/webgpu/index.tsx b/packages/fiber/src/webgpu/index.tsx index 3cecfc4eed..1017c022ec 100644 --- a/packages/fiber/src/webgpu/index.tsx +++ b/packages/fiber/src/webgpu/index.tsx @@ -41,3 +41,40 @@ export type { WebGPUDefaultProps, WebGPUShadowConfig, } from '../../types/webgpu' + +//* WebGPU-narrowed state hooks ============================== +// `export * from '../core'` above brings in useThree/useFrame declared against the *base* +// RootState, whose `renderer` is the R3FRenderer union (WebGLRenderer included). On this entry +// that union is already resolved — the caller has committed to WebGPU — so leaving it in place +// forced a cast for anything WebGPU-only: +// +// const renderer = useThree((s) => s.renderer) +// renderer.compute(node) // Property 'compute' does not exist on type 'R3FRenderer' +// +// which is exactly the friction the split entry points exist to remove. These explicit exports +// shadow the star re-exports (ESM and TS both give a local export precedence) and re-declare the +// two hooks against WebGPURootState. Types only: the values are the core implementations +// untouched, so there is no runtime cost and no second code path to keep in sync. +// See https://github.com/pmndrs/react-three-fiber/issues/3851 +import { useThree as useThreeCore, useFrame as useFrameCore } from '../core' +import type { FrameCallback, UseFrameNextOptions, FrameNextControls } from '@pmndrs/scheduler' +import type { WebGPURootState } from '../../types/webgpu' + +/** `useThree` narrowed to WebGPU state — `state.renderer` is a `WebGPURenderer`. */ +export type UseThreeWebGPU = ( + selector?: (state: WebGPURootState) => T, + equalityFn?: (state: U, newState: U) => boolean, +) => T + +/** `useFrame` narrowed to WebGPU state — the callback's `state.renderer` is a `WebGPURenderer`. */ +export type UseFrameWebGPU = ( + callback?: FrameCallback, + priorityOrOptions?: number | UseFrameNextOptions, +) => FrameNextControls + +// The two signatures are structurally incompatible (the selector parameter makes them +// contravariant), so the re-type has to go through `unknown`. It is sound: WebGPURootState is +// the same object the base hook already returns, only with renderer/gl/internal narrowed to what +// this entry guarantees at runtime. +export const useThree = useThreeCore as unknown as UseThreeWebGPU +export const useFrame = useFrameCore as unknown as UseFrameWebGPU diff --git a/packages/fiber/tests/webgpu/entry-types.test.tsx b/packages/fiber/tests/webgpu/entry-types.test.tsx new file mode 100644 index 0000000000..7dc751960e --- /dev/null +++ b/packages/fiber/tests/webgpu/entry-types.test.tsx @@ -0,0 +1,63 @@ +/** + * WebGPU entry type narrowing — compile-time guard for #3851. + * + * The assertions that matter here are checked by `tsc`, not by vitest: the repo's tsconfig + * includes `packages/**\/*`, so `pnpm typecheck` compiles this file on every CI run and a + * regression in the entry's declared types fails the build. `typeAssertions()` is deliberately + * never called — its body only has to *compile*. + * + * Before the fix, `useThree`/`useFrame` on this entry came from the star re-export of `../core` + * and were typed against the base RootState, whose `renderer` is the R3FRenderer union + * (WebGLRenderer included). Every WebGPU-only call needed a cast: + * + * const renderer = useThree((s) => s.renderer) as unknown as WebGPURenderer + * + * The runtime test below pins the other half of the contract: this is a types-only narrowing, + * so the exported values must still be the core implementations. + */ +import type { WebGPURenderer } from 'three/webgpu' + +import { useThree, useFrame } from '../../src/webgpu' +import { useThree as useThreeCore, useFrame as useFrameCore } from '../../src/core' +import type { RootState as WebGPURootState } from '../../src/webgpu' + +/** Never invoked — this exists so `tsc` checks the bodies. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function typeAssertions() { + // useThree: the selected renderer is a WebGPURenderer, no cast. + const renderer: WebGPURenderer = useThree((s) => s.renderer) + // The WebGPU-only members from the issue must resolve. + renderer.compute(null as any) + void renderer.computeAsync + void renderer.backend + + // useThree with no selector hands back the narrowed state. + const state: WebGPURootState = useThree() + const fromState: WebGPURenderer = state.renderer + + // `gl` is narrowed too — on the base RootState it is a WebGLRenderer. + const gl: WebGPURenderer = useThree((s) => s.gl) + + // useFrame's callback state is narrowed the same way. This is the `DepthAttachmentSync` + // pattern from the docs, which previously needed + // (state.renderer as unknown as { backend?: { updateSize?(): void } }).backend + useFrame((frameState) => { + const frameRenderer: WebGPURenderer = frameState.renderer + frameRenderer.compute(null as any) + void frameState.delta + }) + + // A selector returning something other than the renderer still infers normally. + const width: number = useThree((s) => s.size.width) + + void [renderer, state, fromState, gl, width] +} + +describe('webgpu entry: narrowed state hooks (#3851)', () => { + it('re-exports the core implementations unchanged (types-only narrowing)', () => { + // If these ever diverge, the entry has grown a second code path to keep in sync — which is + // exactly what re-typing rather than wrapping was meant to avoid. + expect(useThree).toBe(useThreeCore) + expect(useFrame).toBe(useFrameCore) + }) +})