Skip to content

Construction firewall - #54

Merged
bigmistqke merged 35 commits into
solidjs-community:nextfrom
bigmistqke:construction-firewall
May 26, 2026
Merged

bigmistqke merged 35 commits into
solidjs-community:nextfrom
bigmistqke:construction-firewall

Conversation

@bigmistqke

@bigmistqke bigmistqke commented May 26, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #53 (WebGPU support). Will conflict-rebase cleanly onto next once #53 merges.

The bug this fixes

<Canvas camera={{ position: pos() }}> (where pos is a signal) recreated the entire PerspectiveCamera on every setPos call. Anything holding a ref to the camera ended up with a stale instance after the first signal change. The same pattern affected scene, raycaster, and most catastrophically gl — where each recreation orphaned a WebGL context.

Root cause was in two places:

  1. meta() augmentation spread invoked the get props() getter at memo-body time. The signals that getter read were captured into the calling memo's deps, defeating any upstream firewall.
  2. Construction memos tracked the wrong things — they read props.camera directly to do instanceof checks, 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() augmentation
Replaced { ..., ...augmentation } with mergeProps. Getters on augmentation (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 content
Each 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 existing useProps() effects.

3. feat(canvas): accept [ctorArgs, properties] tuple for the gl prop
WebGLRenderer 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:

<Canvas gl={[
  { antialias: false, powerPreference: "low-power" },   // ctor
  { toneMapping: THREE.NoToneMapping }                  // properties
]} />
  • tuple[0] drives construction. Shallow-equal changes don't recreate; shape-different changes do recreate, with proper disposal of the previous renderer.
  • tuple[1] is applied via useProps reactively.
  • Single-object gl={{...}} form is now "properties only" — natural reading. Use the tuple if you need ctor args.

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:

  • camera: shape-equal prop change does NOT recreate
  • camera: orthographic flag flip DOES recreate
  • scene / raycaster / gl: shape-equal prop change does NOT recreate

Four tuple tests:

  • tuple is accepted and tuple[1] is applied as instance properties
  • tuple[1] change with shape-equal tuple[0] does NOT recreate
  • tuple[0] shape change DOES recreate
  • the previous renderer is disposed on recreation

bigmistqke added 30 commits May 3, 2026 11:57
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
bigmistqke force-pushed the construction-firewall branch from bbd7e1f to a635c3c Compare May 26, 2026 13:07
@pkg-pr-new

pkg-pr-new Bot commented May 26, 2026

Copy link
Copy Markdown

commit: a635c3c

@bigmistqke
bigmistqke merged commit 9dbfc69 into solidjs-community:next May 26, 2026
2 checks passed
@bigmistqke
bigmistqke deleted the construction-firewall branch June 3, 2026 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant