Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/fiber/src/webgpu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T = WebGPURootState>(
selector?: (state: WebGPURootState) => T,
equalityFn?: <U>(state: U, newState: U) => boolean,
) => T

/** `useFrame` narrowed to WebGPU state — the callback's `state.renderer` is a `WebGPURenderer`. */
export type UseFrameWebGPU = (
callback?: FrameCallback<WebGPURootState>,
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
63 changes: 63 additions & 0 deletions packages/fiber/tests/webgpu/entry-types.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading