Repository navigation
Construction firewall - #54
Merged
bigmistqke merged 35 commits intoMay 26, 2026
Merged
Conversation
otherwise changes in ref={() => ...} on three-elements (like ctx.camera, ctx.raycaster, ...) are immediately overwritten by the effects following the useRef.
thank you @clinuxrulz for reporting the bug!
see 5edff06 for the initial fix in next-solid-2
CONTRIBUTION.md was a 2023 project-scaffold copy; the TC39 stage process and "Design Maxims" sections were never adopted. Rename to the conventional filename (so GitHub picks it up) and trim to what we actually use. Adds a Code Style section documenting the no-non-null-assertion and no-abbreviated-names rules.
Widens Canvas's gl prop and Context.gl from WebGLRenderer to a structural RendererLike interface, so users can pass a WebGPURenderer, SVGRenderer, CSS2DRenderer, or any custom renderer matching the same shape. The new type covers the API solid-three actually uses (render, setSize, setPixelRatio, getPixelRatio) plus optional fields that may or may not exist on a given renderer (xr, shadowMap, init, hasInitialized). Downstream: - create-three xr handlers now early-return when context.gl.xr is undefined (was implicit before, now type-required) - props.ts colorSpace assignment widens to texture's literal type via cast - utils.ts hasColorSpace generic switches from three's Renderer to RendererLike - orbit-controls playground reads three.canvas instead of three.gl.domElement (WebGPURenderer has no domElement) - existing WebGL-only tests cast state.gl to WebGLRenderer at the assertion site Behavior under WebGL is unchanged; subsequent commits wire up the renderer detection and async init() flow.
Reorder the gl memo so a renderer instance (matched structurally via isRendererInstance) is recognised regardless of class — previously the WebGLRenderer instanceof check ran first and anything else fell through to a brand-new default WebGLRenderer, silently dropping a user-supplied WebGPURenderer. Color space and tone-mapping effects are now gated to WebGLRenderer; the config-object useProps branch is gated to non-instance, non-factory inputs.
WebGPURenderer ships an async init() that must complete before the first render — otherwise the first frame crashes inside the backend. Gate the render loop on a new glInitialized flag that flips true after init resolves (synchronously for renderers without init, so WebGL is unaffected). - Skip init when the renderer reports hasInitialized() === true, so users can pass a pre-initialized renderer (matches r3f's #3651 fix). - Pre-size the canvas backing buffer (rect × devicePixelRatio) before init so WebGPU's depth attachment matches the container; otherwise the default 300×150 buffer mismatches on the first resize. - Use onCleanup with a cancelled flag to abort a stale init when the gl memo swaps mid-await.
Adds 7 tests for the new gl-prop paths: - RendererLike instance accepted directly - RendererLike instance returned from the gl factory - renderer.init() awaited before the first render - init() skipped when hasInitialized() returns true - bare RendererLike (no init) renders immediately - outputEncoding/toneMapping not assigned to a non-WebGL renderer - xr.connect/disconnect are no-ops when the renderer has no xr manager
WebGPURenderer exposes the same `outputColorSpace` and `toneMapping` API as WebGLRenderer; the previous `instanceof WebGLRenderer` guard meant a flat/linear scene rendered with WebGPU silently kept its defaults. - Replace `instanceof WebGLRenderer` with structural `'prop' in gl` checks so both renderers get the props applied, while exotic renderers (SVGRenderer, custom) that lack them are skipped cleanly. - Drop the legacy `outputEncoding` setter (numeric 3000/3001 constants) in favour of `outputColorSpace` with `LinearSRGBColorSpace`/`SRGBColorSpace`. solid-three already pins `three ^0.164.1`, well past r152 where this became the canonical API. Matches r3f's approach. Update tests: existing `outputEncoding` assertion swapped for `outputColorSpace`; split the external-renderer color-management test in two — one for renderers that lack the props (skipped) and one for renderers that expose them (applied).
Workflows installed `corepack@latest` and ran `corepack enable` without pinning pnpm, so corepack pulled pnpm 11.3.0 — which requires Node 22.13+ and crashes on Node 20 trying to load `node:sqlite`. Bumping the runner keeps us on the latest pnpm without a version pin.
pnpm 10+ ignores install scripts by default and exits non-zero when any were skipped. esbuild needs its postinstall to fetch the platform binary. Allowlist it via `pnpm.onlyBuiltDependencies`.
pnpm 11 stopped reading the `pnpm` field in package.json; settings live in pnpm-workspace.yaml now. Without this, esbuild's postinstall is skipped and install exits non-zero on CI.
The `onlyBuiltDependencies` config in pnpm-workspace.yaml isn't being honoured by pnpm 11.3 on the runner — install still exits non-zero on ignored esbuild postinstall scripts. Use the explicit `--allow-build` flag instead; it's non-interactive and matches what `pnpm approve-builds` would do.
pnpm v11 removed `onlyBuiltDependencies` and replaced it with `allowBuilds` (a map of `<pkg>: true|false`, not a list). The list form silently does nothing under v11, which is why install kept failing. Revert the `--allow-build=esbuild` flag from the install step — that flag lives on `pnpm add` / `pnpm approve-builds`, not `pnpm install`. Ref: https://pnpm.io/settings#allowbuilds
…URenderer
three 0.164's `WebGPURenderer` declares a stub `xr: { enabled: boolean }`
and `shadowMap: { enabled; type }` — neither satisfies our previous
all-required RendererLike shape, so `new WebGPURenderer()` wasn't even
assignable to the `gl` prop.
- `xr`: keep `enabled` required; mark `isPresenting` and the listener
methods optional, since renderer/build variants ship different subsets.
- `shadowMap.needsUpdate`: optional (absent on WebGPURenderer).
Mirror r3f's gating pattern: the outer "should we wire up XR?" check now
inspects `addEventListener` presence on `renderer.xr` rather than just
truthiness, so a stub xr object doesn't trigger a no-op session manager.
Inner guards in `handleSessionChange` / `xr.connect` / `xr.disconnect`
remain as defense if `context.gl` swaps mid-session.
- webgpu-simple: hands a WebGPURenderer factory to <Canvas>, lit TorusKnot with MeshStandardMaterial. Smallest possible WebGPU demo. - webgpu-tsl: same geometry with MeshBasicNodeMaterial driven by a TSL colorNode (timerLocal / uv / sin / mix / vec3). Shows that procedural GPU-side animation needs no per-frame uniform setting. Imports go through `three/addons/...` and `three/nodes` since three 0.164 predates the `three/webgpu` subpath export.
Matches r3f v10's WebGPU peer pin. Side effects: - canvas snapshot regenerated (the `data-engine` attribute that three auto-adds to its canvas bumped from "three.js r164" to "three.js r181") - the color-management test was using `gl.outputColorSpace = "test"` as a sentinel; three 0.181's renderer now crashes on the next frame when asked to convert from an unregistered color space, so swap to a valid-but-wrong value (`LinearSRGBColorSpace`) for the same intent.
…Like Replaces the structural-only Meta<RendererLike> on Context.gl with a tagged union that gives WebGL and WebGPU users exact three typing (including their bespoke `xr` / `shadowMap` manager classes) while still letting custom renderers (SVGRenderer, CSS2DRenderer, user-built) satisfy the structural `RendererLike` half. Inspired by r3f's pattern in `store.ts` — they type `state.gl` concretely to dodge structural-manager-typing pain. We keep RendererLike too so the door stays open to non-three renderers. RendererLike now declares optional `xr` and `shadowMap` typed as the union of the two concrete renderers' fields (`WebGLRenderer["xr"] | WebGPURenderer["xr"]`), so union access on `Renderer` works without `'field' in gl` checks. The vendor surfaces are still incompatible enough that calls require narrowing — see the create-three.tsx duck-typing. The Canvas `gl` prop, Context.gl, and utils.hasColorSpace all switch from `RendererLike` to `Renderer`.
The Renderer union surfaces three's WebGL/WebGPU split honestly: `xr` and
`shadowMap` have the same field names but different classes
(`WebXRManager` vs `XRManager`, `WebGLShadowMap` vs `{ enabled, type }`)
with incompatible APIs.
- New `isWebXRManager` / `isWebGLShadowMap` type guards in utils.ts use
duck-typing (`setAnimationLoop` / `needsUpdate` as discriminators) to
narrow without importing the manager classes as runtime values — only
`import type` is needed, so bundle impact is zero.
- XR session wiring (handleSessionChange, xr.connect, xr.disconnect, and
the connect effect) gates on `isWebXRManager`. WebGPURenderer's
`XRManager` is correctly excluded — its session API differs and would
need its own integration code.
- The shadow-map effect runs the common `enabled` / `type` writes for any
renderer with a shadowMap; only the WebGL-specific `needsUpdate = true`
write is gated on `isWebGLShadowMap`.
- New `getPendingInit` util encapsulates the `init` / `hasInitialized`
detection. `WebGLRenderer` has no `init` method at all, so direct
union-access errors; the util casts through `RendererLike` to make the
access well-typed and contain the cast to one place. Returns the
init function or `undefined` based on whether init is needed.
Color management was already structurally checked via `'outputColorSpace'
in gl` / `'toneMapping' in gl`; no changes there.
three 0.181 ships dedicated `three/webgpu` and `three/tsl` subpaths; swap the playground examples off the older `three/addons/...` and `three/nodes` paths. webgpu-tsl: also rename `timerLocal` → `time` (TSL renamed it in a recent release; new name matches what the broader TSL docs use).
Three new tests exercising the duck-typed narrowings, plus three unit tests for the `getPendingInit` util: External RendererLike (integration): - XR wiring is skipped when `renderer.xr` is a WebGPU-style stub (has `enabled` and `addEventListener` but no `setAnimationLoop`); proves the `isWebXRManager` discriminator works. - shadowMap.enabled and .type are applied even when the renderer's shadowMap lacks `needsUpdate` (WebGPU shape); only the WebGL-specific `needsUpdate = true` write is gated. getPendingInit (unit): - returns undefined when init is absent - returns undefined when hasInitialized() reports true - returns a callable that invokes init() with the renderer as `this`
`CSS2DRenderer`, `CSS3DRenderer`, and `SVGRenderer` render to DOM — they have no `setPixelRatio` or `getPixelRatio` because there's no backing-buffer concept to scale. solid-three was requiring both methods on `RendererLike` and would crash at runtime calling them. - Mark `setPixelRatio` / `getPixelRatio` optional on `RendererLike`. - `canvas.tsx` resize observer calls `setPixelRatio?.(...)`. - `context.dpr` getter falls back to `globalThis.devicePixelRatio` when the renderer has no `getPixelRatio()`. Adds a test covering a pixel-ratio-less renderer end-to-end.
Demonstrates solid-three's `RendererLike` openness: CSS3DRenderer has none of `xr`, `shadowMap`, `setPixelRatio`, `getPixelRatio`, or `init` — it's pure DOM positioning in 3D space. solid-three drives it the same way it drives WebGL/WebGPU: same render loop, same scene graph, same `useFrame` hook. Renders six labelled cards in a slowly rotating carousel. The gl factory mounts the renderer's `domElement` next to the underlying canvas so the HTML elements show up over the (unused) WebGL surface.
The previous fallback of `globalThis.devicePixelRatio` fabricated a value the renderer never claimed — DOM renderers don't scale a backing buffer, so reporting the device's ratio was a small lie. Honest default: `1`. Users who need the device's ratio for non-rendering math read `globalThis.devicePixelRatio` directly, or pass a renderer that actually exposes `getPixelRatio`.
Solid's JSX returns a DOM element directly, so we can build the card with a `<div>` literal and cast it through `unknown` to `HTMLDivElement`. Less imperative than `document.createElement` + `cssText` string.
Unlike CSS3DRenderer which positions HTML in 3D space, SVGRenderer rasterises the actual three.js scene (meshes, geometries, materials, lights) into vector SVG paths. Same scene graph as a WebGL scene; only the renderer differs. Surfaces the full minimal-RendererLike contract: `render`, `setSize`, `setPixelRatio` (no-op), no `getPixelRatio`, no `xr`, no `shadowMap`, no `init`. Lighting still works because the SVGRenderer applies the material's lit color when rasterising each face.
When `Context.gl` was widened to the `Renderer` union, `domElement` was dropped from `RendererLike` and the playground OrbitControls was switched to read `three.canvas` instead of `three.gl.domElement`. That swap had a subtle wrong-ness: it relied on the renderer's overlay being marked `pointer-events: none` so events fell through to the underlying canvas. The SVG example missed that hack and would have captured input on the SVG element, dropping clicks before they reached the canvas OrbitControls was attached to. The renderer-natural answer is to attach to the renderer's own `domElement` — `<canvas>` for WebGL/WebGPU, `<div>` for CSS3D, `<svg>` for SVGRenderer. All four renderers we care about have it, typed as the common supertype `Element`. - Add `domElement: Element` (required) to `RendererLike`. - Revert OrbitControls back to `three.gl.domElement` with an `as HTMLElement` cast at the call site (three's OrbitControls.connect is typed for HTMLElement; SVGElement works in practice). - Drop the `pointer-events: none` workaround from the CSS3D example. - Test fakes now include a `domElement` so the structural contract is honest at the boundary.
solid-three's Renderer union accepts renderers that don't have a WebXR-shaped `xr` manager (CSS3DRenderer, SVGRenderer, WebGPURenderer with its own XRManager, custom). Calling `context.xr.connect()` on those was a silent no-op — debuggable only by reading source. Now logs a console.warn so "why isn't XR working" surfaces in the console. Existing no-op tests updated to assert the warning is emitted.
Adds a `Register` interface as a module-augmentation point — users
declare their concrete renderer choice in a project-local .d.ts and
`useThree().gl`, `Context.gl`, and the `<Canvas gl>` prop all type-narrow
project-wide.
```ts
// src/solid-three.d.ts
import type { WebGPURenderer } from "three/webgpu"
declare module "solid-three" {
interface Register {
renderer: WebGPURenderer
}
}
```
With this declaration, `useThree().gl.init()` is typed without
narrowing, and passing a wrong renderer to `<Canvas gl>` is a type
error.
Same pattern as Vite's `ImportMetaEnv`, Next's `getServerSideProps`,
React Router's `Register`. r3f doesn't have an equivalent — their answer
was the heavyweight one (separate build entry points per renderer).
Without augmentation `Register` stays empty and `ResolvedRenderer` falls
back to the open `Renderer` union — fully backwards compatible.
Adds a "Custom renderers" subsection under Canvas covering: - The Renderer union (WebGL/WebGPU/SVG/CSS3D/RendererLike). - Links to the four playground examples. - The Register module-augmentation pattern for narrowing `gl` typing project-wide. Also updates the inline `CanvasProps` reference type to use `ResolvedRenderer` so the documentation matches the actual signature.
When a user augments `Register { renderer: WebGPURenderer }` (or any
non-WebGL renderer), the `<Canvas gl>` config-object shorthand is now a
type error. The shorthand only ever builds a default `WebGLRenderer` at
runtime; without this constraint, narrowing to WebGPU + passing
`{ antialias: true }` would silently produce a renderer that violates
the declared type.
```ts
declare module "solid-three" {
interface Register { renderer: WebGPURenderer }
}
<Canvas gl={{ antialias: true }}> // ✗ now a type error (was a footgun)
<Canvas gl={c => new WebGPURenderer({ canvas: c })}> // ✓
```
No behavior change without narrowing — `WebGLRenderer extends Renderer`
(the default union) is true, so the config branch stays available.
`useThree()` now exposes `gl` as the open `Renderer` union (or whatever narrow type `Register` augmentation declares) — not the bare `WebGLRenderer` it used to be. `dpr` falls back to `1` for DOM-based renderers without `getPixelRatio`. Updates the bullet list in the `useThree` section to match.
The `gl={{ antialias: true }}` shorthand only builds a default
WebGLRenderer at runtime — when `Register` narrows the renderer to
something else (e.g. WebGPURenderer), the shorthand is a type error.
Previously the docs only showed the factory-form error; add a third
example covering the config-shorthand case.
`meta(instance, { get props() { ... } })` was spreading the augmentation
into the data object: `{ ..., ...augmentation }`. Spreading invokes every
getter at call time, freezing the value AND tracking whatever signals the
getter touched into the calling reactive scope.
That second effect is the real bug — calling `meta()` inside a
`createMemo` body would silently subscribe the memo to anything its
augmentation getters read, defeating any upstream firewall.
Fix: use `Object.defineProperties` with descriptor copy, so the original
getter is installed on the data object. Reactivity stays where it
belongs (consumers of `.props` re-track) and `meta()` itself adds no
hidden dependencies to its caller.
`<Canvas camera={{ position: pos() }}>` (with `pos` a signal) used to
re-run the camera memo on every `pos` update, allocating a fresh
PerspectiveCamera and breaking object identity for anything holding a
ref. Same pattern affected `scene`, `raycaster`, and `gl`.
Fix: each construction memo now branches on a *cheap* derived memo —
`cameraIsInstance`/`sceneIsInstance`/`raycasterIsInstance` (boolean
`instanceof` check), `orthographicFlag` (boolean), `glKind`
("factory" | "instance" | "default"). These are === comparable, so a
fresh JSX getter call producing the same category doesn't propagate.
The full prop object is read only inside the branch that needs it (the
"user passed an instance" branch), so config-object content changes
don't subscribe the memo body.
Adds 5 firewall tests:
- camera memo: shape-preserving prop change does NOT recreate
- camera memo: orthographic flag flip DOES recreate
- scene memo: shape-preserving prop change does not recreate
- raycaster memo: same
- gl memo: same
Depends on the previous `meta()` getter-preservation fix — without that,
`meta(_, { get props() { return props.camera || {} } })` would itself
subscribe the memo body to `props.camera` and defeat the firewall.
Same effect as the previous descriptor-copy approach (getters preserved, not invoked at merge time, no hidden signal tracking leaking to the caller) but expressed with Solid's own primitive for the job. Less ad-hoc code, more idiomatic.
WebGLRenderer has construction-only parameters (`antialias`,
`powerPreference`, `premultipliedAlpha`, …) that can't be set after the
WebGL context exists. The single-`Partial<Props>` form couldn't express
them. Adds a `[constructorArgs, properties]` tuple shape that splits
them explicitly:
```tsx
<Canvas gl={[
{ antialias: false, powerPreference: "low-power" }, // ctor
{ toneMapping: THREE.NoToneMapping } // properties
]} />
```
Semantics:
- tuple[0] drives construction. Changes (by shallow equality) trigger
renderer recreation; the previous renderer is disposed if solid-three
built it (factory/instance-supplied renderers are left alone).
- tuple[1] is applied via `useProps` reactively — same path as the
single-object form.
- Single-object form is now "properties only", which is the natural
reading. Anyone who needs ctor args opts in to the tuple.
The `shallowEqual` helper that previously sat unused on `utils.ts` is
restored — it backs the `glConstructorArgs` memo's equality so a
fresh-reference / same-content tuple[0] (typical JSX getter output)
doesn't recreate the renderer.
Tests cover: tuple acceptance, tuple[1] application, no recreation on
shape-equal tuple[0] change, recreation on shape-different tuple[0]
change, disposal of the prior renderer on swap.
bigmistqke
force-pushed
the
construction-firewall
branch
from
May 26, 2026 13:07
bbd7e1f to
a635c3c
Compare
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds on #53 (WebGPU support). Will conflict-rebase cleanly onto
nextonce #53 merges.The bug this fixes
<Canvas camera={{ position: pos() }}>(whereposis a signal) recreated the entirePerspectiveCameraon everysetPoscall. Anything holding a ref to the camera ended up with a stale instance after the first signal change. The same pattern affectedscene,raycaster, and most catastrophicallygl— where each recreation orphaned a WebGL context.Root cause was in two places:
meta()augmentation spread invoked theget props()getter at memo-body time. The signals that getter read were captured into the calling memo's deps, defeating any upstream firewall.props.cameradirectly to doinstanceofchecks, subscribing to every signal the JSX getter touched, when only a couple of derived facts (is it an instance?,is orthographic?) actually decide which class to construct.The fix
Three layers, each independent and testable:
1.
fix(utils): preserve getters in meta() augmentationReplaced
{ ..., ...augmentation }withmergeProps. Getters onaugmentation(e.g.get props() { return props.camera || {} }) are now installed as descriptors on the data object rather than invoked at merge time. Reactivity stays where it belongs,meta()adds no hidden deps to its caller.
2.
feat(canvas): firewall construction memos against reactive prop contentEach construction memo (
camera,scene,raycaster,gl) now branches on a small set of===-comparable derived memos (cameraIsInstance,orthographicFlag,sceneIsInstance,raycasterIsInstance,glKind). The full prop object is read only inside the branch that needs it (the "user supplied a concrete instance" branch). Config-object branches don't subscribe to the prop's content — the contents are applied later via the existinguseProps()effects.3.
feat(canvas): accept [ctorArgs, properties] tuple for the gl propWebGLRenderer has construction-only params (
antialias,powerPreference,premultipliedAlpha, …) that can't be set after the WebGL context exists. The single-Partial<Props>form couldn't express them. Adds a[constructorArgs, properties]tuple shape:A small refactor(utils): use mergeProps in meta() instead of defineProperties commit replaces the descriptor-copy implementation with Solid's own primitive for the same effect.
Tests
Five firewall tests covering the four construction memos:
Four tuple tests: