Types: the ScopedStore surface (`useLocalNodes`, `uniforms.scope`, `nodes.scope`) — uniforms need a double cast, nodes are `any`
The ScopedStore surface: uniforms need a double cast, nodes are any
Version: @react-three/[email protected], [email protected], [email protected], React 19.2.
Follow-on to #3886 (store uniforms vs material node slots) and #3769 (useUniforms inference). Both of those are about what comes out of the hook. This one is about the other consumption path — the CreatorState wrappers that useLocalNodes and useNodes creators receive — where the same unknown shows up again, plus two problems specific to the wrapper types.
Everything below was checked with tsc --noEmit against alpha.4. It came out of porting one workshop scene to Vite, Next and Astro (R3F-Workshop/v10-starter), so each case is something we actually had to write a cast for.
What each path infers today
| Expression | Inferred type | Usable in TSL without a cast |
|---|---|---|
useUniforms({ uColor: '#fafafa' }, 'cubes').uColor |
UniformNode<unknown> |
no |
useUniforms({ uAmount: 0.5 }).uAmount |
UniformNode<unknown> |
no |
useUniform('uSolo', 0) |
UniformNode<number> |
no — value type kept, node type still unknown |
useLocalNodes(({ uniforms }) => uniforms.scope('cubes').uColor) |
UniformNode<unknown> |
no |
useNodes(() => ({ wobble: vec3(uniform(0), 0, 0) }), 'probe').wobble |
VarNode<"vec3", JoinNode<"vec3">> |
yes |
useNodes('probe').wobble |
TSLNodeLike |
no |
useLocalNodes(({ nodes }) => nodes.scope('probe').wobble) |
any |
compiles, but unchecked |
The creator path of useNodes is the one place the precise node type survives. Everything read back out of the store loses it.
1. Uniforms reached through uniforms.scope()
Same root cause as #3886 — the node type is pinned to unknown — but it lands on TSL operator arguments rather than material slots:
const build = useCallback(({ uniforms }: CreatorState) => {
const cubes = uniforms.scope('cubes')
return { colorNode: mix(cubes.uBaseColor, color('#000'), 0.5) }
}, [])TS2769: No overload matches this call.
Argument of type 'UniformNode<unknown>' is not assignable to parameter of type 'Vec3 | Vec4OrFloat'.
Type 'UniformNode<unknown>' is not assignable to type 'Node<"color">'.
Type 'UniformNodeClass<unknown> & ... & { __TypeScript_NODE_TYPE__: unknown; } & InputNodeInterface<unknown>'
is missing the following properties from type 'NodeExtensions<"color">':
context, uniformFlow, builtinShadowContext, builtinAOContext, and 6 more.Method chaining fails differently, which is worth calling out because it's the form most TSL code is written in:
uHoverColor.mul(0.4)
// TS2339: Property 'mul' does not exist on type 'UniformNode<unknown>'.2. The ScopedStore docblock example doesn't compile
dist/webgpu/index.d.ts:3745-3752 advertises exactly this, with an explicit promise:
useLocalNodes(({ uniforms }) => ({
wobble: sin(uniforms.uTime.mul(2)), // No cast needed!
playerHealth: uniforms.scope('player').uHealth
}))Pasted verbatim into a project it fails with the TS2339 above — .mul does not exist on UniformNode<unknown>. Whichever way the fix goes, this example should compile or come out.
3. The nodes side of the wrapper is any
CreatorState types nodes as ScopedStoreType<TSLNodeType> (index.d.ts:3791, TSLNodeType at :1244). Property access on it resolves to any:
const { n } = useLocalNodes(({ nodes }: CreatorState) => ({ n: nodes.scope('probe').wobble }))
const isNever: never = n // TS2322: Type 'any' is not assignable to type 'never'
const s: string = n // compiles
const x: number = n.whatever // compilesSo the uniforms half of the wrapper is wrong-but-checked, and the nodes half is unchecked. This is the worse of the two failure modes: a typo in a node name is a runtime undefined with nothing flagged at build time. I haven't chased down which member of the Node | ShaderCallable<Node> | ShaderNodeObject<Node> union brings the any in, but a union with one any member collapses to any, which fits what we see.
4. Creator return vs read-back
// full type preserved
const { wobble } = useNodes(() => ({ wobble: vec3(uniform(0), 0, 0) }), 'probe')
// ^? VarNode<"vec3", JoinNode<"vec3">>
// same node, read back
const { wobble } = useNodes('probe')
// ^? TSLNodeLikeSince the store is keyed by scope and name at runtime, the read-back can't recover what the creator knew. Worth deciding whether reads are meant to be typed by the caller (a generic on useNodes/.scope()) or to stay opaque — right now they're opaque in three different ways (TSLNodeLike, UniformNode<unknown>, any) depending on which door you come through.
What this looks like in real code
Every store read that touches TSL gets laundered. From the starter:
// the cast has to go through `unknown` — a direct `as` is rejected (TS2352)
const cubes = uniforms.scope('cubes') as unknown as {
uBaseColor: ColorUniform
uHoverColor: ColorUniform
}and, for a material slot:
/** R3F aliases UniformNode with three's nodeType pinned to `unknown`, so store
* uniforms satisfy none of the material's `Node<'color'>`-style slots and won't
* even direct-cast. Launder it in one place; delete once r3f types land. */
const asNode = <T extends string>(u: unknown) => u as Node<T>Suggested direction
- Fixing the root (#3769 / #3886) — letting a real
TNodeTypeflow instead ofunknown— resolves 1 and 2 as a side effect, since the wrapper just re-exposes the store's element type. ScopedStoreTypecould take a caller-supplied map so scopes can be typed at the call site, e.g.uniforms.scope<CubeUniforms>('cubes'). That is the one place the caller genuinely knows more than the store does.- For the nodes half, anything is better than
any— evenNodealone would catch typos. - Drop or fix the
// No cast needed!example.
Happy to open a PR for any of these if that's useful — say which shape you'd want.
Repro
Single file, tsc --noEmit against alpha.4:
import { useCallback } from 'react'
import { useUniforms, useNodes, useLocalNodes, type CreatorState } from '@react-three/fiber/webgpu'
import { color, mix, sin, uniform, vec3 } from 'three/tsl'
export function Probe() {
const { uColor } = useUniforms({ uColor: '#fafafa' }, 'cubes')
const a = mix(uColor, color('#000'), 0.5) // TS2769
const b = uColor.mul(0.4) // TS2339
const build = useCallback(({ uniforms }: CreatorState) => ({
c: mix(uniforms.scope('cubes').uColor, color('#000'), 0.5), // TS2769
d: sin(uniforms.uTime.mul(2)), // TS2339 — the docblock example
}), [])
useLocalNodes(build)
const { wobble } = useNodes(() => ({ wobble: vec3(uniform(0), 0, 0) }), 'probe')
const e = useNodes('probe').wobble // TSLNodeLike
const { n } = useLocalNodes(({ nodes }: CreatorState) => ({ n: nodes.scope('probe').wobble }))
const g: string = n // compiles — `any`
return { a, b, wobble, e, g }
}Source: pmndrs/react-three-fiber