From 2a7fc62b332f2ca4dbd09b83eb6abdaa2eb0ddf7 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 29 May 2026 18:25:08 +0200 Subject: [PATCH 01/27] docs: design for decoupling XR from the render loop Hand XR loop ownership to the consumer so core depends only on the stable cross-renderer contract (setAnimationLoop-before-setSession + xr.isPresenting), insulating it from the diverging, still-moving WebGLRenderer vs WebGPURenderer XR semantics. (cherry picked from commit 6035ad8f006c7d63e61e97547caf436f002464b9) --- ...26-05-29-xr-frameloop-decoupling-design.md | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md diff --git a/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md b/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md new file mode 100644 index 00000000..a1c40d74 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md @@ -0,0 +1,285 @@ +# Decouple XR from the render loop: design + +**Date:** 2026-05-29 +**Area:** `src/create-three.tsx`, `src/utils.ts`, `src/types.ts` +**Status:** design approved, pending spec review + +## Problem + +solid-three currently tries to *manage* the WebXR session lifecycle internally: it +auto-connects `sessionstart`/`sessionend` listeners and, on `sessionstart`, toggles +`renderer.setAnimationLoop(handleXRFrame)` to take over the frame loop +(`create-three.tsx:133-166`). + +This breaks on `WebGPURenderer`. The two three.js renderer families wire the XR frame +callback completely differently: + +| | `WebGLRenderer` (`WebXRManager`) | `WebGPURenderer` (`XRManager`) | +|---|---|---| +| `renderer.setAnimationLoop(cb)` | live + XR-aware: also calls `xr.setAnimationLoop(cb)` (three.module.js:16600) | dumb: only sets `renderer._animation` (three.webgpu.js:57227) | +| how the XR frame callback is captured | `xr.setAnimationLoop` stores it live — settable any time | `setSession` **snapshots** `renderer._animation.getAnimationLoop()` at call time (three.webgpu.js:54525), then installs its own `_onAnimationFrame` wrapper | +| per-frame XR work | `WebXRManager.onAnimationFrame` binds the XR framebuffer, then calls the stored cb | `_onAnimationFrame` does `setOutputRenderTarget(xrRenderTarget)` (54...), then calls the snapshot (`_currentAnimationLoop`, 55242) | + +Because solid-three installs its loop **after** `setSession` (on the `sessionstart` +event, which three dispatches *after* the snapshot at 54663-54665), the WebGPU manager +has already snapshotted the wrong loop, and solid-three's later +`renderer.setAnimationLoop(...)` overwrites the manager's own `_onAnimationFrame`. The +result: `setOutputRenderTarget(xrRenderTarget)` never runs, `gl.render` draws to the +default target, the XR layer is never submitted → "Not all layers submitted" every frame ++ headset hang. + +r3f has the same gap: it toggles `gl.xr.setAnimationLoop` on `sessionstart`, but the +WebGPU `XRManager` has no `setAnimationLoop` method at all (verified on three r184 and +`dev`). Its v10 branch casts to `any` and would throw at runtime. No released r3f +supports WebGPU-backend XR. + +## Key insight + +There is exactly **one rule that satisfies both renderer families**, derived from three's +source (not from r3f): + +> Call `renderer.setAnimationLoop(advance)` **before** `setSession`. + +- `WebGLRenderer`: routes `advance` into the live `xr.setAnimationLoop` immediately. +- `WebGPURenderer`: `advance` is on `renderer._animation` when `setSession` snapshots it, + so it becomes `_currentAnimationLoop` and is wrapped by `_onAnimationFrame`. + +Whoever owns "enter XR" is the natural place to obey this rule — and that is the +**consumer**, because they own the AR/VR button (`navigator.xr.requestSession` → +`renderer.xr.setSession`). solid-three never sees that call, which is precisely why +reacting to `sessionstart` is always too late. + +**Decision: hand the XR wiring to the consumer.** solid-three core stops managing the XR +lifecycle and shrinks to a single responsibility: *don't double-drive the frame loop +while a session is presenting.* + +## Design goal: insulate core from diverging, moving-target renderer semantics + +The deeper motivation is not this one bug — it is that **`WebGLRenderer` and +`WebGPURenderer` expose XR through two separately-evolving managers whose exact semantics +diverge and are still actively changing.** The current breakage is just the first place +that divergence surfaced. Evidence the WebGPU XR surface is a live moving target: + +- The classic `WebXRManager` (WebGL) is mature and live-routed; the common `XRManager` + (WebGPU) is newer, snapshot-based, and lacks methods the classic one has (e.g. no + `xr.setAnimationLoop`). +- WebGPU-backend XR threw outright on r181–r184 (`getContextAttributes` before the + `isWebGPUBackend` guard); native WebGPU-backend XR (`_isWebGPUSession` / + `_initWebGPUSession`) only merged to three's `dev` post-r184 and is still unreleased. +- r3f has not caught up: its v10 branch calls `gl.xr.setAnimationLoop` (nonexistent on the + WebGPU manager) and casts to `any`; no released r3f supports WebGPU-backend XR. + +If solid-three core encodes *how* either manager wires its loop — which method to call, +on which object, at which lifecycle event, per renderer family — then core is coupled to +internals that are guaranteed to keep shifting across three releases, and to new renderer +implementations (`RendererLike`, future backends) it has never seen. + +So the decomposition's value is the **narrow, stable contract** it depends on. Core relies +on exactly two things both families already share and that are unlikely to churn: + +1. `renderer.setAnimationLoop(cb)` exists, and a loop installed **before** `setSession` is + the one invoked per XR frame (the single rule that already reconciles both families). +2. `renderer.xr?.isPresenting` is a readable boolean meaning "the session owns the loop." + +Everything family-specific — snapshot vs. live routing, framebuffer binding, the XR camera +swap, layer submission — stays **inside three**, where it belongs and where it can change +freely. By pushing the *act* of installing the loop out to the consumer (who calls +`setSession` and therefore controls timing), core never has to know which manager it is +talking to. A new backend or a changed manager only has to honor those two points to work +with solid-three unchanged. + +## `isPresenting` + +`renderer.xr.isPresenting` is a plain boolean three.js maintains on the XR manager of +both renderer families (`false` initially; `true` set inside `setSession` right before the +`sessionstart` dispatch — three.module.js:13521 / three.webgpu.js:54663; back to `false` +on session end — 13272 / 55009). It means "a live XR session is driving this renderer +right now." It is the single renderer-agnostic selector for "the headset owns the frame +loop," and it is read-only as far as solid-three is concerned. + +## Decomposition + +**Unit A — `advance(timestamp, frame?)`: render exactly one frame.** +This is the existing internal `render(timestamp, frame)` (`create-three.tsx:176`): run +before-listeners → `gl.render(scene, camera)` → after-listeners, forwarding `frame` to +`useFrame((ctx, delta, frame) => …)`. It has no opinion about who calls it or how often. +It is the seam every frame source plugs into. It is exposed on the context as **`advance`**. + +**Unit B — frame source (scheduler): when `advance` is called.** +Four mutually-exclusive sources; exactly one is active: +- `frameloop="always"` → continuous window rAF (`loop`, `create-three.tsx:574`) +- `frameloop="demand"` → window rAF on invalidation (`requestRender`, 193) +- `frameloop="never"` → manual only +- **presenting** → the XR session's rAF, via `renderer.setAnimationLoop(advance)` + +The selector between "a window source" and "the XR source" is `gl.xr?.isPresenting`. + +**Unit C — the transition, split by ownership:** +- **(c1) Consumer:** install `advance` + enable, *before* `setSession`. +- **(c2) Core:** the window rAF yields while presenting and resumes after. + +## Consumer contract (no utilities) + +The **renderer construction differs** per family (it's the consumer's `` +choice); the **enter/exit XR code is byte-identical** for both. That identity is the whole +payoff of the design — see the per-renderer trace below for why it lands the same. + +### Renderer setup — `WebGLRenderer` + +```tsx +// default: constructs a WebGLRenderer, or pass one explicitly + new WebGLRenderer({ canvas })}> + + +``` + +### Renderer setup — `WebGPURenderer` + +```tsx +import { WebGPURenderer } from "three/webgpu" + +// forceWebGL: true is required for XR on three ≤ r184 (WebGPU-backend XR throws; +// native WebGPU-backend XR is unreleased). Drop it once a release ships it. + new WebGPURenderer({ canvas, forceWebGL: true })}> + + +``` + +### Enter / exit XR — identical for both renderers + +```ts +const { gl, advance } = useThree() + +// enter XR — BEFORE the AR/VR button calls setSession: +gl.setAnimationLoop(advance) +gl.xr.enabled = true +const session = await navigator.xr.requestSession('immersive-vr', sessionInit) +await gl.xr.setSession(session) // typically performed by three's ARButton/VRButton + +// exit XR: +gl.setAnimationLoop(null) +gl.xr.enabled = false +``` + +### Why the same code lands correctly on each + +Same call, two internal paths, both ending at `advance(time, frame)`: + +- **`WebGLRenderer`:** `gl.setAnimationLoop(advance)` routes `advance` into the live + `xr.setAnimationLoop` immediately (three.module.js:16600). `setSession` then starts the + session loop; each frame the manager binds the XR framebuffer and calls `advance`. +- **`WebGPURenderer`:** `gl.setAnimationLoop(advance)` only sets `renderer._animation`. + `setSession` **snapshots** that loop into `_currentAnimationLoop` (three.webgpu.js:54525) + and wraps it in `_onAnimationFrame`; each frame `_onAnimationFrame` calls + `setOutputRenderTarget(xrRenderTarget)` then the snapshot `advance`. This only works + because `advance` was installed *before* `setSession` ran. + +No utilities ship in this iteration. A convenience helper (`solid-three/xr` entry, or a +`createXRSession` / `` built on these primitives) is deferred; it would be pure +userland sugar over the contract above and can be designed later without changing core. + +## Core changes + +### `src/create-three.tsx` + +**Delete** the entire current XR block (133-166): +- `handleXRFrame` +- `handleSessionChange` (the `_gl.setAnimationLoop(...)` toggle) +- the `xr` object (`connect` / `disconnect`) +- `warnNonXR` + +**Add** core's only remaining XR responsibility — keep its own window loop out of the +headset's way. A render effect that, for the current renderer exposing an event-target +`xr`, attaches two listeners that manage *core's own* loop (never the renderer's): + +```ts +createRenderEffect(() => { + const _gl = gl() + const xr = (_gl as { xr?: EventTarget & { isPresenting?: boolean } }).xr + if (!xr || typeof xr.addEventListener !== "function") return + + const stop = () => pendingLoopRequest && cancelAnimationFrame(pendingLoopRequest) + const resume = () => { + if (canvasProps.frameloop === "always") pendingLoopRequest = requestAnimationFrame(loop) + else requestRender() // one repaint so the flat canvas reflects post-XR state + } + xr.addEventListener("sessionstart", stop) + xr.addEventListener("sessionend", resume) + onCleanup(() => { + xr.removeEventListener("sessionstart", stop) + xr.removeEventListener("sessionend", resume) + }) +}) +``` + +**Guard the window schedulers only** against presenting, so a stray +`requestRender`/invalidation during XR can't sneak in a window render. The guard goes in +the *schedulers*, never in `advance` itself — the XR session calls `advance` precisely +while `isPresenting` is true, so guarding `advance` would blank the headset: + +- `loop` (574): `if (context.gl?.xr?.isPresenting) return` before `context.render(value)` + (also fixes the existing latent `frameloop="always"` + XR double-drive bug). +- `requestRender` (193): same guard before scheduling the rAF. +- `advance` (the per-frame fn at 176): **no guard** — it must run on demand from any source. + +**Expose** the per-frame primitive on the context as `advance` (the function currently +named `render` internally and exposed on `Context` as `render`). Internal callers +(`loop`) invoke it directly. + +### `src/types.ts` + +- `Context`: rename `render` → `advance`; remove `xr` (the `connect`/`disconnect` object, + ~240). `gl.xr` (the three manager) is what consumers now use directly. `requestRender` + stays. +- Keep `FrameListenerCallback = (context, delta, frame?: XRFrame) => void` (259) — the + `XRFrame` already flows to listeners; unchanged. + +### `src/utils.ts` + +- Remove `canDriveXR` (~292) and its usages — core no longer branches on whether a + renderer "can host an XR session." Any renderer with a `setAnimationLoop` and an + event-target `xr` works; renderers without simply never present, and the + `isPresenting` guard reads `undefined` (falsy) on them. + +## Behavior changes + +- **Breaking:** `context.xr.connect()/disconnect()` removed; `context.render` → + `context.advance`. (Target branch is unreleased `next`, so acceptable.) +- `frameloop="never"` no longer suppresses XR frames. Previously `handleXRFrame` + early-returned on `"never"`; now the consumer drives `advance` directly via the session, + so the headset always renders regardless of `frameloop`. This is the correct behavior — + `frameloop` governs the *window* loop, not the headset. +- `frameloop="always"` + XR no longer double-renders. +- Core no longer emits the `warnNonXR` console warning. + +## Testing + +Per project convention, tests run in real Chromium via vitest browser mode (no jsdom). +A real headset/WebXR session can't be driven in CI, so tests target Unit B (the +scheduler) against a **stub renderer** that satisfies the duck-type: + +- Stub renderer with `xr` as an `EventTarget` exposing a mutable `isPresenting` and + `setAnimationLoop`/`render` spies. +- Assert: while `isPresenting === true` (after dispatching a synthetic `sessionstart`), + the window `loop`/`requestRender` does **not** call `gl.render`. +- Assert: dispatching `sessionend` resumes the window loop (`always`) or issues exactly + one repaint (`demand`). +- Assert: `useFrame` callbacks receive the `frame` argument when `advance(t, frame)` is + invoked. +- Assert: swapping the renderer detaches old listeners and attaches new ones (no leak). + +Real WebGL/WebGPU XR session rendering is verified manually on-device (Quest 3) — this is +the original report that motivated the work. + +## Out of scope + +- Convenience helper / `solid-three/xr` entry (deferred — pure sugar over the contract). +- Native WebGPU-backend XR (three merged it to `dev` post-r184, unreleased; this design is + forward-compatible because it only depends on `setAnimationLoop` + `isPresenting`, both + present on the new path). +- Controller/hand model ergonomics — consumers use three's addons directly via `gl.xr`. + +## Open question + +- Final name of the per-frame primitive: `advance(timestamp?, frame?)` (recommended, + matches "advance one frame") vs. keeping `render`. Everything else is settled. From 638045da9a87e69dce54b0a87b717870cdf90d8b Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 29 May 2026 18:33:34 +0200 Subject: [PATCH 02/27] docs: refine XR decoupling spec + add implementation plan Spec: one sessionend resume listener + self-stopping isPresenting guard (was two listeners); add moving-target motivation and per-renderer consumer contract. Plan: TDD task breakdown. (cherry picked from commit e06a2f4a52d44e72465d6cc46d05e1d73005637f) --- .../2026-05-29-xr-frameloop-decoupling.md | 393 ++++++++++++++++++ ...26-05-29-xr-frameloop-decoupling-design.md | 31 +- 2 files changed, 410 insertions(+), 14 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md diff --git a/docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md b/docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md new file mode 100644 index 00000000..f3010eba --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md @@ -0,0 +1,393 @@ +# XR Frameloop Decoupling — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop solid-three from internally driving the WebXR frame loop; hand that wiring to the consumer and reduce core to a single responsibility — keep its own window render loop out of the way while an XR session is presenting. + +**Architecture:** Core depends only on the stable cross-renderer contract: `renderer.setAnimationLoop(cb)` (installed by the consumer *before* `setSession`) and the read-only boolean `renderer.xr.isPresenting`. All family-specific XR mechanics stay inside three. Core's window loop self-stops via an `isPresenting` guard and resumes via one `sessionend` listener. Spec: `docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md`. + +**Tech Stack:** TypeScript, SolidJS, three.js (r181 in repo; consumers on r184), vitest browser mode (Playwright + Chromium, software WebGL via SwiftShader). jsdom unsupported. + +**Commands:** +- Single test file: `pnpm exec vitest run tests/core/renderer.test.tsx` +- Filter by name: `pnpm exec vitest run tests/core/renderer.test.tsx -t "yields the window"` +- Full suite: `pnpm test` +- Typecheck: `pnpm lint:types` + +--- + +### Task 1: Fix `Context.render` type to forward the XR frame + +The consumer passes `context.render` to `renderer.setAnimationLoop`, which calls it with `(timestamp, frame)`. The type currently claims `(delta: number) => void`, dropping the frame. `XRFrame` is a global DOM type already used at `types.ts:259` (no import needed). + +**Files:** +- Modify: `src/types.ts:234` + +- [ ] **Step 1: Update the type** + +In `src/types.ts`, change the `Context` member: + +```ts +// before + render: (delta: number) => void +// after + render: (timestamp: number, frame?: XRFrame) => void +``` + +- [ ] **Step 2: Verify typecheck passes** + +Run: `pnpm lint:types` +Expected: exit 0, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/types.ts +git commit -m "fix(types): Context.render forwards the XRFrame argument" +``` + +--- + +### Task 2: Remove obsolete XR tests + +Five tests in `tests/core/renderer.test.tsx` assert the behavior being removed (`context.xr.connect/disconnect`, the `setAnimationLoop`-on-`sessionstart` toggle, the `xr.enabled` toggle, the `canDriveXR` skip, and `frameloop="never"` suppression of core-driven XR frames). They are replaced by Task 3's tests. Note: the old "wire XR on a WebGPU-shaped renderer" test only checked that `setAnimationLoop` was *called* — never that it ran before `setSession` — which is exactly why the bug shipped green. + +**Files:** +- Modify: `tests/core/renderer.test.tsx` + +- [ ] **Step 1: Delete the five obsolete tests** + +Delete these `it(...)` blocks in full (match by title): +1. `"should toggle render mode in xr"` +2. `'should respect frameloop="never" in xr'` +3. `"should no-op xr.connect/disconnect when renderer has no xr manager"` +4. `"should wire XR on a WebGPU-shaped renderer (setAnimationLoop on the renderer, not on xr)"` +5. `"should skip XR wiring when renderer.xr lacks setAnimationLoop (WebGPU-style stub)"` + +Keep the `useFrame` import (Task 3 reuses it) and the `makeFakeRenderer` / `RendererLike` helpers (other tests use them). + +- [ ] **Step 2: Verify the suite is still green (old core code unchanged)** + +Run: `pnpm exec vitest run tests/core/renderer.test.tsx` +Expected: PASS (fewer tests; remaining ones unaffected). + +- [ ] **Step 3: Verify typecheck passes** + +Run: `pnpm lint:types` +Expected: exit 0. (`state.xr` is no longer referenced in tests, but still exists on `Context` until Task 3 — so no type error yet.) + +- [ ] **Step 4: Commit** + +```bash +git add tests/core/renderer.test.tsx +git commit -m "test: remove obsolete internally-managed XR tests" +``` + +--- + +### Task 3: Decouple the XR frame loop (red → green) + +This is the core change: delete the internal XR driver, reduce core to the `isPresenting` guard + one `sessionend` resume listener, and remove `canDriveXR` and `Context.xr`. + +**Files:** +- Modify: `tests/core/renderer.test.tsx` (add helper + 3 tests) +- Modify: `src/create-three.tsx` (delete XR block 133-166; delete `canDriveXR` import line 54; delete auto-connect effect 507-512; guard `requestRender` 193 and `loop` 574; add resume effect after the render-loop block ~583) +- Modify: `src/types.ts` (remove `Context.xr`, lines ~240-243) +- Modify: `src/utils.ts` (remove `canDriveXR`, lines 279-301; drop now-unused imports) + +- [ ] **Step 1: Add the `nextFrames` helper and three failing tests** + +At the top of `tests/core/renderer.test.tsx` (after imports), add: + +```ts +const nextFrames = (n: number) => + new Promise(resolve => { + let i = 0 + const tick = () => (++i >= n ? resolve() : requestAnimationFrame(tick)) + requestAnimationFrame(tick) + }) +``` + +Add these tests inside the existing top-level `describe(...)` block: + +```ts +it("yields the window render loop while an XR session is presenting, resumes after", async () => { + const state = test(() => , { frameloop: "always" }) + const gl = state.gl as unknown as THREE.WebGLRenderer + await state.waitTillNextFrame() // ensure the loop is running normally + + const renderSpy = vi.spyOn(gl, "render") + + // Enter "presenting": the session now owns frames; the window loop must go quiet. + gl.xr.isPresenting = true + gl.xr.dispatchEvent({ type: "sessionstart" }) + await nextFrames(3) + expect(renderSpy).not.toHaveBeenCalled() + + // Exit: window loop resumes. + gl.xr.isPresenting = false + gl.xr.dispatchEvent({ type: "sessionend" }) + await state.waitTillNextFrame() + expect(renderSpy).toHaveBeenCalled() + + renderSpy.mockRestore() +}) + +it("does not touch gl.xr.enabled — the consumer owns it", async () => { + const state = test(() => , { frameloop: "always" }) + const gl = state.gl as unknown as THREE.WebGLRenderer + + expect(gl.xr.enabled).toBe(false) + gl.xr.isPresenting = true + gl.xr.dispatchEvent({ type: "sessionstart" }) + // Core must leave enabled alone; the consumer sets it before setSession. + expect(gl.xr.enabled).toBe(false) +}) + +it("forwards the XRFrame argument through to useFrame listeners", async () => { + let received: XRFrame | undefined + const fakeFrame = {} as XRFrame + const state = test(() => { + useFrame((_ctx, _delta, frame) => { + received = frame + }) + return + }) + + state.render(performance.now(), fakeFrame) + expect(received).toBe(fakeFrame) +}) +``` + +- [ ] **Step 2: Run the new tests — expect failures** + +Run: `pnpm exec vitest run tests/core/renderer.test.tsx -t "yields the window"` and `-t "does not touch gl.xr.enabled"` +Expected: +- "yields the window…" FAILS — old `"always"` `loop` has no guard, so `render` is still called while presenting. +- "does not touch gl.xr.enabled…" FAILS — old `handleSessionChange` sets `gl.xr.enabled = true` on `sessionstart`. +- "forwards the XRFrame…" PASSES already (guard/characterization test; old `render` forwards `frame`). + +- [ ] **Step 3: Delete the internal XR driver in `src/create-three.tsx`** + +Remove the entire XR block (lines 133-166): `handleXRFrame`, the `// Both WebGL and WebGPU…` comment, `warnNonXR`, `handleSessionChange`, and the `xr` object (`connect`/`disconnect`). + +Remove `canDriveXR` from the import list (line 54). + +Remove the auto-connect effect (lines 507-512): + +```ts +// DELETE THIS: + createEffect(() => { + if (canDriveXR(gl())) context.xr.connect() + }) +``` + +Remove the `xr` member from the `context` object literal (the `xr,` entry near line 409). + +- [ ] **Step 4: Guard the window schedulers in `src/create-three.tsx`** + +`requestRender` (line 193) — add the guard first: + +```ts + function requestRender() { + if (context.gl?.xr?.isPresenting) return + if (pendingRenderRequest) return + pendingRenderRequest = requestAnimationFrame(render) + } +``` + +`loop` (line 574) — make the `"always"` chain self-stop while presenting: + +```ts + function loop(value: number) { + if (context.gl?.xr?.isPresenting) { + // The XR session drives advance() now; let this chain die. + // The sessionend listener restarts it. + pendingLoopRequest = undefined + return + } + pendingLoopRequest = requestAnimationFrame(loop) + context.render(value) + } +``` + +- [ ] **Step 5: Add the resume listener in `src/create-three.tsx`** + +Immediately after the render-loop `createRenderEffect` block (after line 583), add: + +```ts + // Core's sole XR responsibility: when the consumer-driven session ends, + // revive the window loop (which self-stopped via the isPresenting guard). + // No sessionstart listener needed — the guard handles stopping. Only + // depends on `setAnimationLoop`+`isPresenting`, shared by both renderer + // families, so no WebGL-vs-WebGPU branching. + createRenderEffect(() => { + const _gl = gl() as { xr?: EventTarget } + const xr = _gl.xr + if (!xr || typeof xr.addEventListener !== "function") return + const resume = () => { + if (canvasProps.frameloop === "always") { + if (!pendingLoopRequest) pendingLoopRequest = requestAnimationFrame(loop) + } else { + requestRender() // one repaint so the flat canvas reflects post-XR state + } + } + xr.addEventListener("sessionend", resume) + onCleanup(() => xr.removeEventListener("sessionend", resume)) + }) +``` + +If `createRenderEffect` / `onCleanup` are not already imported in this file, add them to the `solid-js` import. + +- [ ] **Step 6: Remove `Context.xr` from `src/types.ts`** + +Delete the `xr` member from the `Context` interface (lines ~240-243): + +```ts +// DELETE THIS: + xr: { + connect: () => void + disconnect: () => void + } +``` + +- [ ] **Step 7: Remove `canDriveXR` from `src/utils.ts`** + +Delete the doc comment and function (lines 279-301). Then remove any now-unused imports it required (`WebXRManager`, and `XRFrameRequestCallback` if unused elsewhere) — `pnpm lint:types`/eslint will flag them. + +- [ ] **Step 8: Run the new tests — expect pass** + +Run: `pnpm exec vitest run tests/core/renderer.test.tsx -t "yields the window"` then `-t "does not touch gl.xr.enabled"` then `-t "forwards the XRFrame"` +Expected: all PASS. + +- [ ] **Step 9: Run the full file + typecheck** + +Run: `pnpm exec vitest run tests/core/renderer.test.tsx` then `pnpm lint:types` +Expected: PASS, exit 0. (No remaining references to `context.xr` or `canDriveXR`.) + +- [ ] **Step 10: Commit** + +```bash +git add src/create-three.tsx src/types.ts src/utils.ts tests/core/renderer.test.tsx +git commit -m "refactor(xr): decouple XR loop ownership from core + +Core no longer drives the WebXR frame loop. It keeps its own window loop +out of the way via an isPresenting guard + one sessionend resume listener, +and depends only on the stable cross-renderer contract +(setAnimationLoop-before-setSession + xr.isPresenting). The consumer wires +setAnimationLoop/enabled/setSession. Fixes WebGPURenderer XR (the old +sessionstart toggle ran too late for the WebGPU manager's setSession +snapshot) and the latent frameloop=always + XR double-render." +``` + +--- + +### Task 4 (optional): Renderer-swap listener cleanup test + +Guards the `onCleanup` in the resume effect — swapping the `gl` prop must detach the old renderer's `sessionend` listener. + +**Files:** +- Modify: `tests/core/renderer.test.tsx` + +- [ ] **Step 1: Write the test** + +```ts +it("detaches the sessionend listener when the renderer swaps", async () => { + const first = new THREE.WebGLRenderer({ canvas: document.createElement("canvas") }) + const second = new THREE.WebGLRenderer({ canvas: document.createElement("canvas") }) + const removeSpy = vi.spyOn(first.xr, "removeEventListener") + + const [glAccessor, setGl] = createSignal(first) + const state = test(() => , { + get gl() { + return glAccessor() + }, + }) + expect(state.gl).toBe(first) + + setGl(second) + await state.waitTillNextFrame() + + expect(removeSpy).toHaveBeenCalledWith("sessionend", expect.any(Function)) + + first.dispose() + first.forceContextLoss() + second.dispose() + second.forceContextLoss() + removeSpy.mockRestore() +}) +``` + +Ensure `createSignal` is imported from `solid-js` (it already is in this file). + +- [ ] **Step 2: Run — expect pass** + +Run: `pnpm exec vitest run tests/core/renderer.test.tsx -t "detaches the sessionend"` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add tests/core/renderer.test.tsx +git commit -m "test(xr): verify sessionend listener detaches on renderer swap" +``` + +--- + +### Task 5 (optional, pending naming decision): rename `context.render` → `context.advance` + +Only do this if the user confirms the `advance` name (the one open question in the spec). Functionally inert — `context.render` already works as the consumer's per-frame callback. + +**Files:** +- Modify: `src/types.ts` (the `render` member), `src/create-three.tsx` (definition, the `context` literal entry, the `loop` call at 576), `src/canvas.tsx:106`, `tests/core/renderer.test.tsx` (the `state.render(...)` call in the frame-forward test). + +- [ ] **Step 1: Rename in `src/types.ts`** + +```ts +// before + render: (timestamp: number, frame?: XRFrame) => void +// after + advance: (timestamp: number, frame?: XRFrame) => void +``` + +- [ ] **Step 2: Rename in `src/create-three.tsx`** + +Rename the internal `function render(...)` to `function advance(...)`; update the `context` literal (`render,` → `advance,` — note it's exposed as `advance` now); update the `loop` body call `context.render(value)` → `context.advance(value)`. Leave `requestRender` (which calls the internal fn) pointing at the renamed function. + +- [ ] **Step 3: Rename the call in `src/canvas.tsx:106`** + +```ts +// before + context.render(performance.now()) +// after + context.advance(performance.now()) +``` + +- [ ] **Step 4: Update the test** + +In the "forwards the XRFrame…" test, `state.render(...)` → `state.advance(...)`. + +- [ ] **Step 5: Verify** + +Run: `pnpm exec vitest run tests/core/renderer.test.tsx` then `pnpm lint:types` +Expected: PASS, exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/types.ts src/create-three.tsx src/canvas.tsx tests/core/renderer.test.tsx +git commit -m "refactor(api): rename Context.render to Context.advance" +``` + +--- + +## Documentation follow-up (not code; do after Tasks 1-3 land) + +The original consumer report (Scott / `vorth/webxr-poc`) needs the public guidance the spec describes. Add to the XR docs page: the consumer contract (both renderer setups + identical enter/exit block), the `forceWebGL: true` requirement for `WebGPURenderer` XR on three ≤ r184, and that solid-three no longer auto-manages sessions. Tracked separately from this code plan. + +## Notes for the implementer + +- `gl.xr.isPresenting` and `gl.xr.dispatchEvent` are real, writable members of three's `WebXRManager` — the deleted tests already used them, so the new tests rely on the same surface. +- Do not add an `isPresenting` guard to `render`/`advance` itself — the XR session calls it precisely while `isPresenting` is true; guarding it would blank the headset. The guard belongs only in `loop` and `requestRender`. +- `site/` build artifacts under `.output/`/`.nitro/` reference the old `canDriveXR`/`context.xr`; they are generated and regenerate on build — do not hand-edit them. +- The modified `site/vite.config.ts` in the working tree is unrelated to this work; leave it unstaged. diff --git a/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md b/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md index a1c40d74..9a1df8be 100644 --- a/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md +++ b/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md @@ -189,26 +189,28 @@ userland sugar over the contract above and can be designed later without changin - `warnNonXR` **Add** core's only remaining XR responsibility — keep its own window loop out of the -headset's way. A render effect that, for the current renderer exposing an event-target -`xr`, attaches two listeners that manage *core's own* loop (never the renderer's): +headset's way. This is a **self-stopping guard plus a single `sessionend` listener**: + +- The guard (below) makes the `always` loop chain *die on its own* the first frame + `isPresenting` is true — no `sessionstart` listener needed. +- Only resuming needs a signal, because a dead loop chain can't restart itself when + `isPresenting` flips back to false. One `sessionend` listener does that: ```ts createRenderEffect(() => { const _gl = gl() - const xr = (_gl as { xr?: EventTarget & { isPresenting?: boolean } }).xr + const xr = (_gl as { xr?: EventTarget }).xr if (!xr || typeof xr.addEventListener !== "function") return - const stop = () => pendingLoopRequest && cancelAnimationFrame(pendingLoopRequest) const resume = () => { - if (canvasProps.frameloop === "always") pendingLoopRequest = requestAnimationFrame(loop) - else requestRender() // one repaint so the flat canvas reflects post-XR state + if (canvasProps.frameloop === "always") { + if (!pendingLoopRequest) pendingLoopRequest = requestAnimationFrame(loop) + } else { + requestRender() // one repaint so the flat canvas reflects post-XR state + } } - xr.addEventListener("sessionstart", stop) xr.addEventListener("sessionend", resume) - onCleanup(() => { - xr.removeEventListener("sessionstart", stop) - xr.removeEventListener("sessionend", resume) - }) + onCleanup(() => xr.removeEventListener("sessionend", resume)) }) ``` @@ -217,9 +219,10 @@ createRenderEffect(() => { the *schedulers*, never in `advance` itself — the XR session calls `advance` precisely while `isPresenting` is true, so guarding `advance` would blank the headset: -- `loop` (574): `if (context.gl?.xr?.isPresenting) return` before `context.render(value)` - (also fixes the existing latent `frameloop="always"` + XR double-drive bug). -- `requestRender` (193): same guard before scheduling the rAF. +- `loop` (574): if `context.gl?.xr?.isPresenting`, set `pendingLoopRequest = undefined` + and `return` *without rescheduling* — the chain dies and `sessionend`/`resume` revives + it (also fixes the existing latent `frameloop="always"` + XR double-drive bug). +- `requestRender` (193): if `context.gl?.xr?.isPresenting`, `return` before scheduling. - `advance` (the per-frame fn at 176): **no guard** — it must run on demand from any source. **Expose** the per-frame primitive on the context as `advance` (the function currently From aaa14ee0fe38a48fe3fe430e4bae28a5271235af Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 29 May 2026 18:35:18 +0200 Subject: [PATCH 03/27] fix(types): Context.render forwards the XRFrame argument (cherry picked from commit 53ab6db0f48584e196940008ec1753b246202e9e) --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 87d49c3b..6794b141 100644 --- a/src/types.ts +++ b/src/types.ts @@ -231,7 +231,7 @@ export interface Context { dpr: number gl: Meta props: CanvasProps - render: (delta: number) => void + render: (timestamp: number, frame?: XRFrame) => void requestRender: () => void scene: Meta setCamera(camera: CameraKind): () => void From 62fd800d8a7d6419fb28329f0f667073ddc4bd01 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 29 May 2026 18:36:17 +0200 Subject: [PATCH 04/27] test: remove obsolete internally-managed XR tests (cherry picked from commit 07656071b79296be672328918719d4973cdd0f0c) --- tests/core/renderer.test.tsx | 108 ----------------------------------- 1 file changed, 108 deletions(-) diff --git a/tests/core/renderer.test.tsx b/tests/core/renderer.test.tsx index 695114b8..56c15f8d 100644 --- a/tests/core/renderer.test.tsx +++ b/tests/core/renderer.test.tsx @@ -546,40 +546,6 @@ describe("renderer", () => { expect(gl.outputColorSpace).toBe(THREE.SRGBColorSpace) }) - it("should toggle render mode in xr", async () => { - const state = test(() => ) - const xr = (state.gl as unknown as THREE.WebGLRenderer).xr - - xr.isPresenting = true - xr.dispatchEvent({ type: "sessionstart" }) - - expect(xr.enabled).toEqual(true) - - xr.isPresenting = false - xr.dispatchEvent({ type: "sessionend" }) - - expect(xr.enabled).toEqual(false) - }) - - it('should respect frameloop="never" in xr', async () => { - let respected = true - - const TestGroup = () => { - useFrame(() => { - respected = false - }) - return - } - const state = test(() => , { frameloop: "never" }) - const xr = (state.gl as unknown as THREE.WebGLRenderer).xr - xr.isPresenting = true - xr.dispatchEvent({ type: "sessionstart" }) - - await new Promise(resolve => requestAnimationFrame(resolve)) - - expect(respected).toEqual(true) - }) - it("will render components that are extended", async () => { const testExtend = async () => { const T = createT({ MyColor }) @@ -759,80 +725,6 @@ describe("renderer", () => { expect(fake.toneMapping).toBe(THREE.ACESFilmicToneMapping) }) - it("should no-op xr.connect/disconnect when renderer has no xr manager", async () => { - const fake = makeFakeRenderer() - const state = test(() => , { gl: fake }) - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) - - expect(() => state.xr.connect()).not.toThrow() - expect(() => state.xr.disconnect()).not.toThrow() - // The no-op path warns so users debugging "why isn't my XR working" can - // see it in the console. - expect(warn).toHaveBeenCalledTimes(2) - expect(warn.mock.calls[0][0]).toMatch(/no-op/) - - warn.mockRestore() - }) - - it("should wire XR on a WebGPU-shaped renderer (setAnimationLoop on the renderer, not on xr)", async () => { - // WebGPURenderer's XRManager has no `setAnimationLoop` — that method lives on - // the renderer itself. On `sessionstart`, solid-three must: - // 1. set `gl.xr.enabled = true` - // 2. drive frames via `gl.setAnimationLoop(cb)` on the renderer (not xr). - // On `sessionend`, it must clear both. - const listeners: Record void)[]> = {} - const xrManager = { - enabled: false, - isPresenting: false, - addEventListener: (type: string, fn: (e: unknown) => void) => { - ;(listeners[type] ??= []).push(fn) - }, - removeEventListener: (type: string, fn: (e: unknown) => void) => { - listeners[type] = (listeners[type] ?? []).filter(l => l !== fn) - }, - dispatch(type: string) { - for (const fn of listeners[type] ?? []) fn({ type }) - }, - } - const setAnimationLoop = vi.fn() - const fake = Object.assign(makeFakeRenderer(), { xr: xrManager, setAnimationLoop }) - - test(() => , { gl: fake }) - - xrManager.isPresenting = true - xrManager.dispatch("sessionstart") - - expect(xrManager.enabled).toBe(true) - expect(setAnimationLoop).toHaveBeenCalledTimes(1) - expect(typeof setAnimationLoop.mock.calls[0][0]).toBe("function") - - xrManager.isPresenting = false - xrManager.dispatch("sessionend") - - expect(xrManager.enabled).toBe(false) - expect(setAnimationLoop).toHaveBeenLastCalledWith(null) - }) - - it("should skip XR wiring when renderer.xr lacks setAnimationLoop (WebGPU-style stub)", async () => { - // WebGPURenderer's XRManager has `enabled` but no `setAnimationLoop`. The - // duck-typed `isWebXRManager` guard must distinguish this from a real - // WebXRManager so we don't crash calling missing methods. - const addEventListener = vi.fn() - const fake = Object.assign(makeFakeRenderer(), { - xr: { enabled: false, addEventListener }, - }) - const state = test(() => , { gl: fake }) - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) - - expect(() => state.xr.connect()).not.toThrow() - // Real wiring would have called addEventListener twice (sessionstart, - // sessionend). The guard should have skipped it. - expect(addEventListener).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledTimes(1) - - warn.mockRestore() - }) - it("should accept a renderer without setPixelRatio/getPixelRatio (CSS/SVG-style)", async () => { // DOM-based renderers (CSS2DRenderer, CSS3DRenderer, SVGRenderer) have no // pixel-ratio API. They must still work — `context.dpr` falls back to `1` From f44441df24a0e538de30a33cc0b618fbb961ea7a Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 29 May 2026 19:32:55 +0200 Subject: [PATCH 05/27] refactor(xr): decouple XR loop ownership from core Core no longer drives the WebXR frame loop. It keeps its own window loop out of the way via an isPresenting guard + one sessionend resume listener, and depends only on the stable cross-renderer contract (setAnimationLoop-before-setSession + xr.isPresenting). The consumer wires setAnimationLoop/enabled/setSession. Fixes WebGPURenderer XR (the old sessionstart toggle ran too late for the WebGPU manager's setSession snapshot) and the latent frameloop=always + XR double-render. (cherry picked from commit e8b434cdec3f249401c327d83df58b3abedc1cab) --- src/create-three.tsx | 76 ++++++++++++------------------------ src/types.ts | 4 -- src/utils.ts | 25 ------------ tests/core/renderer.test.tsx | 54 +++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 79 deletions(-) diff --git a/src/create-three.tsx b/src/create-three.tsx index dd1846b8..6cf9e047 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -51,7 +51,6 @@ import { getPendingInit, isRenderer, isWebGLShadowMap, - canDriveXR, meta, removeElementFromArray, useRef, @@ -124,47 +123,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { } } - /**********************************************************************************/ - /* */ - /* XR */ - /* */ - /**********************************************************************************/ - - // Handle frame behavior in WebXR - const handleXRFrame: XRFrameRequestCallback = (timestamp: number, frame?: XRFrame) => { - if (canvasProps.frameloop === "never") return - render(timestamp, frame) - } - // Both WebGL and WebGPU expose `setAnimationLoop` on the *renderer* — that's - // the XR-aware loop driver. WebGL's `WebXRManager` mirrors it on `xr` as - // well; WebGPU's `XRManager` does not. Driving the loop via the renderer - // unifies both paths. - function warnNonXR(method: string) { - console.warn( - `solid-three: ${method} is a no-op — the active renderer can't host an XR session (needs an event-target \`xr\` manager and \`setAnimationLoop\` on the renderer). Pass a WebGLRenderer or a WebGPURenderer.`, - ) - } - function handleSessionChange() { - const _gl = context.gl - if (!canDriveXR(_gl)) return - _gl.xr.enabled = _gl.xr.isPresenting - _gl.setAnimationLoop(_gl.xr.isPresenting ? handleXRFrame : null) - } - const xr = { - connect() { - const _gl = context.gl - if (!canDriveXR(_gl)) return warnNonXR("xr.connect()") - _gl.xr.addEventListener("sessionstart", handleSessionChange) - _gl.xr.addEventListener("sessionend", handleSessionChange) - }, - disconnect() { - const _gl = context.gl - if (!canDriveXR(_gl)) return warnNonXR("xr.disconnect()") - _gl.xr.removeEventListener("sessionstart", handleSessionChange) - _gl.xr.removeEventListener("sessionend", handleSessionChange) - }, - } - /**********************************************************************************/ /* */ /* Render */ @@ -191,6 +149,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { updateFrameListeners("after", delta, frame) } function requestRender() { + if (context.gl?.xr?.isPresenting) return if (pendingRenderRequest) return pendingRenderRequest = requestAnimationFrame(render) } @@ -406,7 +365,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { get viewport() { return viewport() }, - xr, // elements get camera() { return cameraStack.peek() ?? camera() @@ -504,13 +462,6 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { } }) - createEffect(() => { - // Wire XR only when the active renderer can host a session. Inner - // `xr.connect()`/`disconnect()` guards reinforce this if `context.gl` - // swaps later. - if (canDriveXR(gl())) context.xr.connect() - }) - // Color management and tone-mapping. Both WebGLRenderer and // WebGPURenderer expose `outputColorSpace` and `toneMapping`; we // structurally check so exotic renderers (SVGRenderer, custom) that @@ -572,6 +523,12 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { let pendingLoopRequest: number | undefined function loop(value: number) { + if (context.gl?.xr?.isPresenting) { + // The XR session drives the per-frame render now; let this chain die. + // The sessionend listener restarts it. + pendingLoopRequest = undefined + return + } pendingLoopRequest = requestAnimationFrame(loop) context.render(value) } @@ -582,6 +539,25 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { onCleanup(() => pendingLoopRequest && cancelAnimationFrame(pendingLoopRequest)) }) + // Core's sole XR responsibility: when the consumer-driven session ends, + // revive the window loop (which self-stopped via the isPresenting guard). + // No sessionstart listener needed — the guard handles stopping. Depends only + // on `addEventListener`/`isPresenting`, shared by both renderer families. + createRenderEffect(() => { + const _gl = gl() as { xr?: EventTarget } + const xr = _gl.xr + if (!xr || typeof xr.addEventListener !== "function") return + const resume = () => { + if (canvasProps.frameloop === "always") { + if (!pendingLoopRequest) pendingLoopRequest = requestAnimationFrame(loop) + } else { + requestRender() // one repaint so the flat canvas reflects post-XR state + } + } + xr.addEventListener("sessionend", resume) + onCleanup(() => xr.removeEventListener("sessionend", resume)) + }) + /**********************************************************************************/ /* */ /* Events */ diff --git a/src/types.ts b/src/types.ts index 6794b141..3aaf270d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -237,10 +237,6 @@ export interface Context { setCamera(camera: CameraKind): () => void setRaycaster(camera: Raycaster): () => void viewport: Viewport - xr: { - connect: () => void - disconnect: () => void - } } export interface Viewport { diff --git a/src/utils.ts b/src/utils.ts index 8f2e23a1..d7d43959 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -11,7 +11,6 @@ import { Texture, Vector3, type WebGLShadowMap, - type WebXRManager, } from "three" import { $S3C } from "./constants.ts" import type { @@ -276,30 +275,6 @@ export function isRenderer(value: unknown): value is Renderer { ) } -/** - * Returns true if the renderer can host an XR session: its `xr` manager is an - * event target (so we can subscribe to `sessionstart`/`sessionend`) and the - * renderer itself exposes `setAnimationLoop` (the XR-aware loop driver, which - * lives on the renderer in both WebGL and WebGPU builds — only the WebGL - * `WebXRManager` *also* mirrors it). - * - * Unifies WebGL and WebGPU XR wiring: we always drive the loop via - * `gl.setAnimationLoop(...)` rather than `gl.xr.setAnimationLoop(...)`, which - * three's WebGPU `XRManager` doesn't expose. - */ -export function canDriveXR( - gl: unknown, -): gl is { xr: WebXRManager; setAnimationLoop: (cb: XRFrameRequestCallback | null) => void } { - if (!gl || typeof gl !== "object") return false - const xr = (gl as { xr?: unknown }).xr - const setLoop = (gl as { setAnimationLoop?: unknown }).setAnimationLoop - return ( - !!xr && - typeof (xr as { addEventListener?: unknown }).addEventListener === "function" && - typeof setLoop === "function" - ) -} - /** * Duck-typed narrow to `WebGLShadowMap`. `needsUpdate` is the WebGL-only * field we set; WebGPURenderer's `shadowMap` is `{ enabled, type }` without it. diff --git a/tests/core/renderer.test.tsx b/tests/core/renderer.test.tsx index 56c15f8d..3b3232f6 100644 --- a/tests/core/renderer.test.tsx +++ b/tests/core/renderer.test.tsx @@ -46,6 +46,13 @@ class MyColor extends THREE.Color { } const T = createT({ ...THREE, HasObject3dMember, HasObject3dMethods, MyColor }) +const nextFrames = (n: number) => + new Promise(resolve => { + let i = 0 + const tick = () => (++i >= n ? resolve() : requestAnimationFrame(tick)) + requestAnimationFrame(tick) + }) + beforeAll(() => { Object.defineProperty(globalThis, "devicePixelRatio", { configurable: true, @@ -1138,4 +1145,51 @@ describe("renderer", () => { expect(ref!.children).toStrictEqual([child1, child]) expect(ref!.userData.attach).toBe(attachedChild) }) + + it("yields the window render loop while an XR session is presenting, resumes after", async () => { + const state = test(() => , { frameloop: "always" }) + const gl = state.gl as unknown as THREE.WebGLRenderer + await state.waitTillNextFrame() // ensure the loop is running normally + + const renderSpy = vi.spyOn(gl, "render") + + // Enter "presenting": the session now owns frames; the window loop must go quiet. + gl.xr.isPresenting = true + gl.xr.dispatchEvent({ type: "sessionstart" }) + await nextFrames(3) + expect(renderSpy).not.toHaveBeenCalled() + + // Exit: window loop resumes. + gl.xr.isPresenting = false + gl.xr.dispatchEvent({ type: "sessionend" }) + await state.waitTillNextFrame() + expect(renderSpy).toHaveBeenCalled() + + renderSpy.mockRestore() + }) + + it("does not touch gl.xr.enabled — the consumer owns it", async () => { + const state = test(() => , { frameloop: "always" }) + const gl = state.gl as unknown as THREE.WebGLRenderer + + expect(gl.xr.enabled).toBe(false) + gl.xr.isPresenting = true + gl.xr.dispatchEvent({ type: "sessionstart" }) + // Core must leave enabled alone; the consumer sets it before setSession. + expect(gl.xr.enabled).toBe(false) + }) + + it("forwards the XRFrame argument through to useFrame listeners", async () => { + let received: XRFrame | undefined + const fakeFrame = {} as XRFrame + const state = test(() => { + useFrame((_ctx, _delta, frame) => { + received = frame + }) + return + }) + + state.render(performance.now(), fakeFrame) + expect(received).toBe(fakeFrame) + }) }) From b5b1aba7beb49f3bc61e93fee490f45aac997283 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 29 May 2026 19:34:09 +0200 Subject: [PATCH 06/27] test(xr): verify sessionend listener detaches on renderer swap (cherry picked from commit fc24a4deff006f42ef9a5fe028b0ef4464c4eaa8) --- tests/core/renderer.test.tsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/core/renderer.test.tsx b/tests/core/renderer.test.tsx index 3b3232f6..4dba49f7 100644 --- a/tests/core/renderer.test.tsx +++ b/tests/core/renderer.test.tsx @@ -1192,4 +1192,29 @@ describe("renderer", () => { state.render(performance.now(), fakeFrame) expect(received).toBe(fakeFrame) }) + + it("detaches the sessionend listener when the renderer swaps", async () => { + const first = new THREE.WebGLRenderer({ canvas: document.createElement("canvas") }) + const second = new THREE.WebGLRenderer({ canvas: document.createElement("canvas") }) + const removeSpy = vi.spyOn(first.xr, "removeEventListener") + + const [glAccessor, setGl] = createSignal(first) + const state = test(() => , { + get gl() { + return glAccessor() + }, + }) + expect(state.gl).toBe(first) + + setGl(second) + await state.waitTillNextFrame() + + expect(removeSpy).toHaveBeenCalledWith("sessionend", expect.any(Function)) + + first.dispose() + first.forceContextLoss() + second.dispose() + second.forceContextLoss() + removeSpy.mockRestore() + }) }) From 93badf474a041840b1832045c30b36a9ae354dfe Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 29 May 2026 19:39:45 +0200 Subject: [PATCH 07/27] docs: record naming decision (keep render); drop rename task (cherry picked from commit 6a84ebc8057765cf3c5eaa99d4034f51fe0d2915) --- .../plans/2026-05-29-xr-frameloop-decoupling.md | 11 +++++++++-- .../2026-05-29-xr-frameloop-decoupling-design.md | 12 ++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md b/docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md index f3010eba..a003bd09 100644 --- a/docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md +++ b/docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md @@ -334,9 +334,14 @@ git commit -m "test(xr): verify sessionend listener detaches on renderer swap" --- -### Task 5 (optional, pending naming decision): rename `context.render` → `context.advance` +### Task 5 — DROPPED (naming decision: keep `render`) -Only do this if the user confirms the `advance` name (the one open question in the spec). Functionally inert — `context.render` already works as the consumer's per-frame callback. +Decision: keep `context.render` / `context.requestRender` as-is. The rename to `advance` would have broken the coherent `render`/`requestRender` pairing. No code change. This task is not performed. + +
+Original (not pursued): rename `context.render` → `context.advance` + +Functionally inert — `context.render` already works as the consumer's per-frame callback. **Files:** - Modify: `src/types.ts` (the `render` member), `src/create-three.tsx` (definition, the `context` literal entry, the `loop` call at 576), `src/canvas.tsx:106`, `tests/core/renderer.test.tsx` (the `state.render(...)` call in the frame-forward test). @@ -379,6 +384,8 @@ git add src/types.ts src/create-three.tsx src/canvas.tsx tests/core/renderer.tes git commit -m "refactor(api): rename Context.render to Context.advance" ``` +
+ --- ## Documentation follow-up (not code; do after Tasks 1-3 land) diff --git a/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md b/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md index 9a1df8be..475127c5 100644 --- a/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md +++ b/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md @@ -282,7 +282,11 @@ the original report that motivated the work. present on the new path). - Controller/hand model ergonomics — consumers use three's addons directly via `gl.xr`. -## Open question - -- Final name of the per-frame primitive: `advance(timestamp?, frame?)` (recommended, - matches "advance one frame") vs. keeping `render`. Everything else is settled. +## Resolved decisions + +- **Per-frame primitive name: keep `render`** (with the corrected + `(timestamp, frame?)` type). It pairs coherently with `requestRender` + (do-now / schedule-next-frame); renaming only `render` → `advance` would + break that pairing. The surface collision with `gl.render(scene, camera)` is + acceptable since they live on different objects and consumers rarely call + `gl.render` directly. From 286552b4292c15859ca22d38495e48b626c0ba35 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Fri, 29 May 2026 19:50:26 +0200 Subject: [PATCH 08/27] fix(xr): guard resize render; scope post-XR repaint; fix docs Code-review follow-ups: - canvas.tsx resize no longer issues a window-driven render while an XR session is presenting (the third window driver that bypassed the guard). - extract a single isPresenting() predicate used by loop + requestRender. - sessionend resume repaints only in frameloop="demand", not "never". - useThree docs: correct render signature, drop removed xr member, add a consumer-driven WebXR section. - add coverage for a renderer whose xr manager lacks addEventListener. (cherry picked from commit 878678506c3615ebba7b46e9c15eeb3c67d880fa) --- site/src/routes/api/hooks/use-three.mdx | 25 ++++++++++++++++++++++--- src/canvas.tsx | 5 ++++- src/create-three.tsx | 12 +++++++++--- tests/core/renderer.test.tsx | 13 +++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/site/src/routes/api/hooks/use-three.mdx b/site/src/routes/api/hooks/use-three.mdx index 4e20e9df..949fe518 100644 --- a/site/src/routes/api/hooks/use-three.mdx +++ b/site/src/routes/api/hooks/use-three.mdx @@ -37,11 +37,10 @@ const camera = useThree(context => context.camera) | `gl` | `Meta` | The active renderer, wrapped with [`meta`](/api/utilities/metadata) so you can read solid-three metadata via `getMeta(three.gl)`. Narrow it project-wide with [Register augmentation](/api/components/canvas#narrowing-the-renderer-type-project-wide). | | `raycaster` | `Raycaster \| EventRaycaster` | The current raycaster used for pointer events. | | `setRaycaster` | `(raycaster: Raycaster) => () => void` | Push a raycaster onto the stack. Returns a cleanup that pops it. | -| `render` | `(delta: number) => void` | Manually trigger a render. | -| `requestRender` | `() => void` | Request a render on the next frame. | +| `render` | `(timestamp: number, frame?: XRFrame) => void` | Render one frame now. Pass a timestamp (e.g. `performance.now()`); the optional `XRFrame` is forwarded to [`useFrame`](/api/hooks/use-frame) callbacks. This is the per-frame primitive you hand to `gl.setAnimationLoop` when driving a WebXR session. | +| `requestRender` | `() => void` | Request a render on the next frame. Skipped while an XR session is presenting (the session owns the loop). | | `scene` | `Meta` | The root scene, wrapped with [`meta`](/api/utilities/metadata). | | `props` | `CanvasProps` | The props the host [``](/api/components/canvas) was rendered with. | -| `xr` | `{ connect: () => void; disconnect: () => void }` | WebXR connection management. | ## Behavior @@ -49,6 +48,26 @@ The camera and raycaster are each managed as a **stack**. The `camera` and `rayc `setCamera` and `setRaycaster` push a new one onto the stack, making it active, and return a cleanup that pops it back off — restoring the previous one. Pair the cleanup with `onCleanup` so the previous camera or raycaster comes back when your component unmounts. +### WebXR + +solid-three does not manage XR sessions for you. While a session is presenting, the renderer's own loop (driven by the headset) owns rendering, so solid-three's window loop steps aside automatically. You wire the session directly on the renderer, installing the render callback **before** `setSession` runs: + +```tsx +const { gl, render } = useThree() + +// enter XR +gl.setAnimationLoop(render) // must be set before setSession +gl.xr.enabled = true +const session = await navigator.xr.requestSession("immersive-vr") +await gl.xr.setSession(session) // three's AR/VR button usually does this + +// exit XR +gl.setAnimationLoop(null) +gl.xr.enabled = false +``` + +The same code works for both `WebGLRenderer` and `WebGPURenderer`. For XR with `WebGPURenderer` on three ≤ r184, construct it with `{ forceWebGL: true }` (the WebGPU backend's XR support is unreleased as of r184). + ## Examples Switching to an orthographic camera while a signal is set, and restoring the previous one on cleanup: diff --git a/src/canvas.tsx b/src/canvas.tsx index 09c98414..53b9f565 100644 --- a/src/canvas.tsx +++ b/src/canvas.tsx @@ -103,7 +103,10 @@ export function Canvas(props: ParentProps) { } context.camera.updateProjectionMatrix() - context.render(performance.now()) + // While an XR session owns the frame loop, don't issue a window-driven + // render — the session drives frames. The post-XR repaint happens on + // sessionend. + if (!context.gl?.xr?.isPresenting) context.render(performance.now()) }) }) diff --git a/src/create-three.tsx b/src/create-three.tsx index 6cf9e047..7f9811b3 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -131,6 +131,11 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { let pendingRenderRequest: number | undefined + // True while an XR session owns the frame loop. Window-initiated renders + // (`loop`, `requestRender`, and the resize repaint in canvas.tsx) must yield + // to it; `render` itself stays unguarded because the session calls it. + const isPresenting = () => !!context.gl?.xr?.isPresenting + function render(timestamp: number, frame?: XRFrame) { // `WebGPURenderer.init()` must complete before the first render; the // render loop spins harmlessly until the resource flips to "ready". @@ -149,7 +154,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { updateFrameListeners("after", delta, frame) } function requestRender() { - if (context.gl?.xr?.isPresenting) return + if (isPresenting()) return if (pendingRenderRequest) return pendingRenderRequest = requestAnimationFrame(render) } @@ -523,7 +528,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { let pendingLoopRequest: number | undefined function loop(value: number) { - if (context.gl?.xr?.isPresenting) { + if (isPresenting()) { // The XR session drives the per-frame render now; let this chain die. // The sessionend listener restarts it. pendingLoopRequest = undefined @@ -550,9 +555,10 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { const resume = () => { if (canvasProps.frameloop === "always") { if (!pendingLoopRequest) pendingLoopRequest = requestAnimationFrame(loop) - } else { + } else if (canvasProps.frameloop === "demand") { requestRender() // one repaint so the flat canvas reflects post-XR state } + // "never" is fully manual — the consumer repaints if/when they want to. } xr.addEventListener("sessionend", resume) onCleanup(() => xr.removeEventListener("sessionend", resume)) diff --git a/tests/core/renderer.test.tsx b/tests/core/renderer.test.tsx index 4dba49f7..63ec64ba 100644 --- a/tests/core/renderer.test.tsx +++ b/tests/core/renderer.test.tsx @@ -1217,4 +1217,17 @@ describe("renderer", () => { second.forceContextLoss() removeSpy.mockRestore() }) + + it("does not crash for a renderer whose xr manager lacks addEventListener (WebGPU-style stub)", async () => { + // WebGPURenderer's XRManager has `enabled` but is not an event target in + // older builds. The sessionend effect must skip it, not call a missing + // addEventListener. + const fake = Object.assign(makeFakeRenderer(), { xr: { enabled: false } }) + const state = test(() => , { gl: fake }) + + await state.waitTillNextFrame() + + expect(state.gl).toBe(fake) + expect(fake.render).toHaveBeenCalled() + }) }) From b90c772f09125f4ec714864fc213556bf74b94f5 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 15:23:28 +0200 Subject: [PATCH 09/27] =?UTF-8?q?docs(spec):=20createXR=20=E2=80=94=20cons?= =?UTF-8?q?umer-owned=20XR=20entry=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for a createXR() primitive that encapsulates the three sharp edges of the decoupled XR contract (setAnimationLoop-before-setSession snapshot order, the post-exit double-drive, and transient-activation request-first) plus the structural Canvas-context-to-DOM-button gap. Also wires and widens Canvas's currently-dead ref to support a cleanup return. (cherry picked from commit 99e52ec85f93e6e17b52a1611ad9a546df0a1d0d) --- .../specs/2026-06-02-create-xr-design.md | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-create-xr-design.md diff --git a/docs/superpowers/specs/2026-06-02-create-xr-design.md b/docs/superpowers/specs/2026-06-02-create-xr-design.md new file mode 100644 index 00000000..757a3b74 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-create-xr-design.md @@ -0,0 +1,244 @@ +# `createXR`: a consumer-owned XR entry primitive — design + +**Date:** 2026-06-02 +**Area:** new `src/create-xr.tsx`, small edit to `src/canvas.tsx`, `src/index.ts`, `src/types.ts` +**Status:** design approved, pending spec review +**Depends on:** the XR frameloop decoupling (`docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md`) — this is the consumer-side counterpart to that core change. + +## Problem + +The frameloop decoupling moved XR session ownership out of core: core no longer drives the WebXR loop, it only yields its window loop while `xr.isPresenting` is true and resumes on `sessionend`. The consumer now owns the wiring — and that wiring has two sharp edges they must get exactly right, or XR breaks silently. + +**Edge 1 — the snapshot order.** `renderer.setAnimationLoop(render)` must be installed *before* `renderer.xr.setSession(session)`, because `WebGPURenderer`'s `XRManager` snapshots the animation loop at `setSession` time (`three.webgpu.js:54525`) rather than reading it live. Install it late — e.g. by reacting to `sessionstart` — and the WebGPU manager has already snapshotted the wrong loop; the headset hangs. This is the bug the decoupling diagnosed. + +**Edge 2 — the double-drive after exit (newly identified).** Because three *snapshots* the loop on entry, it also *restores* it on exit: `onSessionEnd` runs `setAnimationLoop(_currentAnimationLoop); start()` (`three.webgpu.js:55012-55015`) before dispatching `sessionend` (`:55020`). If the consumer installed `context.render` as that loop, three now drives `context.render` every frame — and core's own `sessionend` resume listener *also* restarts its window loop driving `context.render`. Result: `context.render` fires twice per frame after the first XR exit, `getDelta()` returns a real delta on the first call and ~0 on the second, and every `useFrame` listener double-fires with a garbage delta. The documented raw contract (`setAnimationLoop` → `enabled` → `setSession`) does not mention cleanup, so it carries this latent bug. + +**Edge 3 — transient activation.** Immersive `navigator.xr.requestSession` is gated on transient user activation (a time-boxed, ~5s, consumable window-global flag set by a user gesture). It must be the first thing the enter flow does, with nothing awaited before it — not because a single `await` is always fatal (transient activation is time-based, not task-bound), but because awaiting anything slow beforehand risks blowing past the window, Chromium's WebXR path has historically been stricter than the pure spec, and the chaining semantics are an open question upstream (immersive-web/webxr#779). Request-first is the only robust-across-engines ordering. + +A correct consumer must get all three right, every time, and also bridge a structural gap: the renderer `Context` (which the wiring needs) is born inside ``, while the "Enter XR" button that triggers `enter()` is a DOM element *outside* it. + +**Goal:** ship one small primitive that encapsulates all three edges and the structural gap, built entirely on solid-three's public surface — proving the decoupled contract is sufficient — so consumers cannot trip any of the edges. + +## Key insight: pull the Context outward, don't push the control outward + +There is exactly one hard constraint: the control logic needs `context.gl` and `context.render`, which exist only after `` mounts, but `enter()` is called from a DOM gesture handler outside Canvas. Reactivity is *not* the problem — `isPresenting()` is a signal and reads correctly anywhere it is referenced. The only thing to bridge is getting a `Context` handle to logic that lives outside Canvas. + +Two directions bridge it: + +1. **Pull the Context outward** — the XR logic lives outside Canvas; Canvas hands its `Context` out (via the `ref` it already exposes). +2. **Push the control outward** — the XR logic lives inside Canvas (where `useThree` works), and `enter`/`exit`/state are lifted out to the DOM by hand. + +Direction 1 wins because when you pull the Context out, the control surface (`enter`, `exit`, `isPresenting`) is *already* in the outer scope where the button is. The thing threaded is the boring handle, not the interesting API. Direction 2 is the bridge-component boilerplate, awkward precisely because it hoists functions and signals out of a component manually. + +So the primitive is: **the consumer creates the XR handle in their component, and Canvas connects its `Context` into it.** + +```tsx +const xr = createXR() // signals live here, in the component's owner scope + + + + +``` + +No provider, no new Canvas prop, no `createSignal` juggling. The button closes over `xr` — the natural Solid idiom. In-scene needs (controller/hand poses) are already served by `useFrame((ctx, delta, frame) => …)`, whose `frame` the decoupling now forwards, so this primitive optimizes for the DOM-button + reactive-`isPresenting` case and does not over-invest in in-scene access. A provider can be layered on later without changing this core. + +## Public API + +`createXR()` is a Solid primitive, called in a component body (it owns one reactive effect, so it needs an owner — like `createSignal`). It is **not** named `useXR`: the `use*` hooks in this library (`useThree`, `useFrame`, `useLoader`) all throw outside ``, whereas this is created outside Canvas. `use*` is reserved for a future provider-reading hook. + +```ts +const xr = createXR() + +xr.connect // Ref — pass to ; returns a cleanup +xr.enter(mode, init?) // request a session + wire it → Promise +xr.enter(session) // escape hatch: wire a session you made → Promise +xr.exit() // end the active session → Promise +xr.isPresenting() // Accessor — driven by three's xr events +xr.session() // Accessor +xr.isSupported(mode) // Promise — thin guard over navigator.xr.isSessionSupported +``` + +Module: new `src/create-xr.tsx`, exported from `src/index.ts`. Built only on the public `Context` (`context.gl`, `context.render`) and the renderer's standard `xr` event target. No core internals, no WebGL-vs-WebGPU branching. + +## Internals + +### State and the single reactive effect + +```ts +export function createXR() { + const [context, setContext] = createSignal() + const [presenting, setPresenting] = createSignal(false) + const [session, setSession] = createSignal() + + // The only moving part. context.gl is a reactive getter (create-three.tsx:389), + // so reading it here re-runs the effect on renderer swap; onCleanup detaches the + // previous manager's listeners. Clearing context() (on Canvas unmount) cascades + // through here to tear everything down. + createRenderEffect(() => { + const gl = context()?.gl + const xr = gl?.xr + if (!gl || !xr || typeof xr.addEventListener !== "function") return + const onStart = () => setPresenting(true) + const onEnd = () => { + setPresenting(false) + setSession(undefined) + gl.setAnimationLoop(null) // Edge 2: stop three re-driving context.render post-exit + gl.xr.enabled = false // symmetric: we set it on enter, we clear it here + } + xr.addEventListener("sessionstart", onStart) + xr.addEventListener("sessionend", onEnd) + onCleanup(() => { + xr.removeEventListener("sessionstart", onStart) + xr.removeEventListener("sessionend", onEnd) + }) + }) + + // ... connect / enter / exit / isSupported below ... + + return { connect, enter, exit, isSupported, isPresenting: presenting, session } +} +``` + +`isPresenting()` is driven by three's authoritative `sessionstart`/`sessionend` events (the decision over a hook-local boolean), so it reflects the renderer's actual state even if a session starts or ends outside `enter`/`exit`, and stays consistent with core's own `sessionend` listener. `session()` is the handle: set in `enter` once obtained, cleared on `sessionend`. + +### `connect` — minimal `Ref` with a cleanup return + +```ts +function connect(context: Context) { + setContext(context) + return () => setContext(undefined) // Canvas unmount → clear → cascades teardown via the effect +} +``` + +`connect` does nothing but stash the context and hand back a disconnect. It reads no members itself; the effect and `enter`/`exit` read `context.gl`/`context.render` lazily. The single requirement `connect` imposes lands on `createXR`, not itself: the effect needs a reactive owner, so `createXR()` is a component-body primitive. + +### `enter` — request-first, then snapshot-safe wiring + +```ts +async function enter(arg: XRSessionMode | XRSession, init?: XRSessionInit): Promise { + const ctx = context() + if (!ctx) throw new Error("S3: createXR().enter() called before connected") + const gl = ctx.gl + if (!gl?.xr) throw new Error("S3: active renderer has no xr manager") + + const session = typeof arg === "string" + ? await requestSession(arg, init) // Edge 3: FIRST. nothing awaited before this. + : arg // escape hatch: activation was the caller's concern + + gl.setAnimationLoop(ctx.render) // Edge 1: before setSession (the snapshot rule) + gl.xr.enabled = true + await gl.xr.setSession(session) // three sets isPresenting=true → core's loop self-stops + setSession(session) + return session +} + +function requestSession(mode: XRSessionMode, init?: XRSessionInit) { + if (!navigator.xr) throw new Error("S3: WebXR unavailable (navigator.xr is undefined)") + return navigator.xr.requestSession(mode, init) +} +``` + +`requestSession` is the only transient-activation-gated step, and it is first with nothing awaited before it. The post-request wiring is unconstrained by activation. `isSupported` (below) is the consumer's button-gating call and is **never** awaited inside `enter`. + +### `exit` and `isSupported` + +```ts +async function exit() { + await session()?.end() // fires sessionend → the effect's onEnd tears down; core resumes the window loop +} + +function isSupported(mode: XRSessionMode): Promise { + return navigator.xr?.isSessionSupported(mode) ?? Promise.resolve(false) +} +``` + +`exit` only calls `end()`; all teardown flows through the `sessionend` listener, keeping one teardown path. `isSupported` is a thin guarded pass-through; consumers wrap it in `createResource` for reactive button gating. + +## Canvas change (the only core edit) + +`canvas.tsx` declares `ref?: Ref` (line 20) but **never invokes it** — the prop is currently dead. This design wires it, and widens it to allow an optional cleanup return (the React 19 cleanup-callback-ref shape): + +```ts +// types: widen the ref beyond Solid's Ref (= T | (val => void)) +ref?: Context | ((context: Context) => void | (() => void)) + +// canvas.tsx, inside onMount, after `const context = createThree(canvas, props)`: +const cleanup = props.ref?.(context) +if (typeof cleanup === "function") onCleanup(cleanup) +``` + +`onCleanup` inside `onMount` registers on the component owner, firing on Canvas unmount. This is XR-blind — "a ref may return a cleanup" is a generic Canvas capability — and it fixes an existing dead prop so `ref` becomes functional for all consumers, not just XR. + +## `forceWebGL` + +Untouched, and deliberately invisible to the hook. Driving `WebGPURenderer` XR through a WebGL2 backend on three ≤ r184 is a `` / renderer-config concern the consumer owns. The hook contains no backend detection and no dev warning — that would re-introduce the family-specific coupling the decoupling exists to remove. The r ≤ r184 limitation is documented, not enforced in code. + +## Usage + +DOM "Enter XR" button (the primary case): + +```tsx +function App() { + const xr = createXR() + return ( + <> + + + + + + + + + ) +} +``` + +AR with feature descriptors, support-gated: + +```tsx +const xr = createXR() +const [supported] = createResource(() => xr.isSupported("immersive-ar")) + + + + +``` + +In-scene (no `createXR` needed — `useFrame` forwards the `XRFrame`): + +```tsx +function Player() { + useFrame((ctx, delta, frame) => { + if (!frame) return // present only during an XR session + // read XRFrame poses, move the rig, etc. + }) + return null +} +``` + +## Testing + +Browser mode (Playwright + Chromium, software WebGL via SwiftShader); jsdom unsupported; no headset. The real WebGPU `setSession` snapshot cannot be exercised here — that residual gap is the same one the decoupling carries, and correctness of the snapshot interaction rests on source reading, not E2E. + +Tests, against a renderer with a writable `isPresenting` and a dispatchable `xr` event target (same surface the decoupling tests already use): + +1. `enter(mode)` calls `setAnimationLoop` **before** `setSession` (order assertion — the Edge 1 guard). +2. `enter(mode)` calls `requestSession` before any `await` / before touching `gl` (Edge 3 guard). +3. `isPresenting()` / `session()` track dispatched `sessionstart` / `sessionend`. +4. `sessionend` calls `setAnimationLoop(null)` (Edge 2 — the double-render regression guard). +5. listeners detach and re-attach on renderer swap (the `context.gl` reactivity). +6. `enter(existingSession)` wires without calling `requestSession`. +7. `enter()` before connect throws the clear error. +8. Canvas invokes a cleanup-returning ref, and runs the returned cleanup on unmount (the Canvas edit). + +## Decisions and rejected alternatives + +- **`createXR()` object over `` / `useXR(ctxAccessor)` / ``.** The store-created-outside, connected-by-`ref` shape solves the DOM-button case with the least surface and keeps Canvas XR-blind. A provider (`useXR()` everywhere) and a dedicated `xr` prop are both viable sugar but were rejected for day one: the provider adds a wrapper + connect wire for in-scene access that `useFrame` largely already covers, and the dedicated prop is the one option that teaches core Canvas about XR. +- **Hook owns the full lifecycle, forwarding (not wrapping) `requestSession`.** `enter` passes `mode` + `sessionInit` straight to `navigator.xr.requestSession`, so the large, spec-churning feature surface is never modeled here. The pre-made-session overload covers the wire-only case without a second exported primitive. +- **Reactive state from three's `xr` events**, not a hook-local boolean, so it reflects true renderer state and stays consistent with core. +- **Renderer swap = reset**, no attempt to migrate a live session across renderers (impossible anyway); listeners re-attach via the `context.gl` reactive read. +- **No automatic, hook-free path.** Earlier analysis confirmed an `always`-mode-only auto-path is possible (core permanently holding `setAnimationLoop(context.render)`), but it re-breaks in `demand`/`never` modes and the only rescue re-couples core to divergent per-family loop semantics. Single explicit path chosen. From cd0c61e4f344ff6b755d82c74b296d5473d45e2c Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 15:31:34 +0200 Subject: [PATCH 10/27] docs(plan): createXR implementation plan (TDD, 6 tasks) (cherry picked from commit d773f15718b2edfd68fd2e4232aef26caff27259) --- .../superpowers/plans/2026-06-02-create-xr.md | 769 ++++++++++++++++++ 1 file changed, 769 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-02-create-xr.md diff --git a/docs/superpowers/plans/2026-06-02-create-xr.md b/docs/superpowers/plans/2026-06-02-create-xr.md new file mode 100644 index 00000000..8eed6005 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-create-xr.md @@ -0,0 +1,769 @@ +# `createXR` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a `createXR()` primitive that lets a consumer enter/exit WebXR correctly on both `WebGLRenderer` and `WebGPURenderer`, encapsulating the three sharp edges of the decoupled XR contract so they cannot be tripped. + +**Architecture:** `createXR()` is a Solid primitive called in a component body. It holds three signals (`context`, `presenting`, `session`) and one reactive effect that attaches `sessionstart`/`sessionend` listeners to the *current* renderer's `xr` manager. The renderer `Context` is handed in from `` — `connect` is a `Ref` that returns a disconnect cleanup. `enter` requests the session first (transient activation), then installs `setAnimationLoop(render)` before `setSession` (the WebGPU snapshot rule); `sessionend` nulls the loop (kills the post-exit double-render). Built only on the public `Context` + the renderer's `xr` event target — no core internals, no WebGL-vs-WebGPU branching. + +**Tech Stack:** TypeScript, SolidJS, three.js (r181 in repo), vitest browser mode (Playwright + Chromium, software WebGL via SwiftShader). jsdom unsupported. + +**Spec:** `docs/superpowers/specs/2026-06-02-create-xr-design.md` + +**Commands:** +- Single test file: `pnpm exec vitest run tests/core/create-xr.test.tsx` +- Filter by name: `pnpm exec vitest run tests/core/create-xr.test.tsx -t "snapshot order"` +- Full suite: `pnpm test` +- Typecheck: `pnpm lint:types` (build first if `solid-three` module errors appear in `site/`: `pnpm build`) + +**Spec correction folded in:** the spec calls `Canvas`'s `ref` "a currently-dead prop." It is **not** dead — `create-three.tsx:597` already calls `useRef(props, context)`, so the ref already fires with the `Context`. The only core change is therefore (1) teach `useRef` to honor a cleanup return, and (2) widen the ref *type*. No new wiring in `canvas.tsx`. + +--- + +## File Structure + +- **Create** `src/create-xr.tsx` — the `createXR()` primitive (one responsibility: consumer-side XR entry/exit + reactive state). +- **Modify** `src/types.ts` — add `RefWithCleanup` type. +- **Modify** `src/utils.ts:480` — `useRef` honors a cleanup-returning ref. +- **Modify** `src/canvas.tsx:20` — widen `CanvasProps.ref` to `RefWithCleanup`. +- **Modify** `src/index.ts` — export `createXR`. +- **Create** `tests/core/use-ref.test.tsx` — `useRef` cleanup behavior. +- **Create** `tests/core/create-xr.test.tsx` — `createXR` behavior. + +--- + +### Task 1: `useRef` honors a cleanup-returning ref + +A function ref may now return a cleanup, run when the ref's owner disposes (or the ref value changes). `create-three.tsx:597` already routes Canvas's `Context` ref through `useRef`, so this single change makes `` support `fn` returning a disconnect — which `createXR().connect` relies on. + +**Files:** +- Modify: `src/types.ts` (add `RefWithCleanup`) +- Modify: `src/utils.ts:480-494` (`useRef`) +- Create: `tests/core/use-ref.test.tsx` + +- [ ] **Step 1: Add the `RefWithCleanup` type** + +In `src/types.ts`, add near the other exported helper types: + +```ts +/** + * A ref that is a value sink, a callback, or a callback returning a cleanup + * (the React-19 cleanup-callback-ref shape). The cleanup runs when the ref's + * reactive owner disposes or the ref value changes. + */ +export type RefWithCleanup = T | ((value: T) => void | (() => void)) +``` + +- [ ] **Step 2: Write the failing test** + +Create `tests/core/use-ref.test.tsx`: + +```tsx +import { createRoot } from "solid-js" +import { describe, expect, it, vi } from "vitest" +import { useRef } from "../../src/utils.ts" + +describe("useRef", () => { + it("invokes a function ref with the value", () => { + const ref = vi.fn() + createRoot(dispose => { + useRef({ ref }, 42) + dispose() + }) + expect(ref).toHaveBeenCalledWith(42) + }) + + it("runs a ref's returned cleanup on dispose", () => { + const cleanup = vi.fn() + const ref = vi.fn(() => cleanup) + const dispose = createRoot(d => { + useRef({ ref }, 42) + return d + }) + expect(cleanup).not.toHaveBeenCalled() + dispose() + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + it("assigns to a non-function ref slot", () => { + const props: { ref?: number } = {} + createRoot(dispose => { + useRef(props, 7) + dispose() + }) + expect(props.ref).toBe(7) + }) +}) +``` + +- [ ] **Step 3: Run the tests — expect one failure** + +Run: `pnpm exec vitest run tests/core/use-ref.test.tsx` +Expected: "invokes a function ref" and "assigns to a non-function ref slot" PASS (current behavior); **"runs a ref's returned cleanup on dispose" FAILS** — current `useRef` ignores the return value. + +- [ ] **Step 4: Implement cleanup support** + +In `src/utils.ts`, change the `Ref` import on line 2 to drop `Ref` (no longer used) and add the type import, then update `useRef`: + +```ts +// at top of src/utils.ts — add to the existing `import type` from "./types.ts" +// (or add a new line if none exists): +import type { RefWithCleanup } from "./types.ts" +``` + +Replace the `useRef` function (currently at `src/utils.ts:480`): + +```ts +export function useRef(props: { ref?: RefWithCleanup }, value: T | Accessor) { + createRenderEffect(() => { + const result = + typeof value === "function" + ? // @ts-expect-error — T may itself be callable; the Accessor branch is intended + value() + : value + if (typeof props.ref === "function") { + const cleanup = (props.ref as (value: T) => void | (() => void))(result) + if (typeof cleanup === "function") onCleanup(cleanup) + } else { + props.ref = result + } + }) +} +``` + +`onCleanup` is already imported in `src/utils.ts:2`. If the `Ref` symbol from `solid-js` is now unused, remove it from the line-2 import (eslint `--max-warnings 0` will flag it otherwise). + +- [ ] **Step 5: Run the tests — expect pass** + +Run: `pnpm exec vitest run tests/core/use-ref.test.tsx` +Expected: all 3 PASS. + +- [ ] **Step 6: Typecheck + full suite (no regressions in existing `useRef` callers)** + +Run: `pnpm exec vitest run tests/core/renderer.test.tsx` then `pnpm lint:types` +Expected: PASS, exit 0. (Existing callers — `create-three.tsx:597`, `testing/index.tsx:97` — pass refs that return `void`, so behavior is unchanged for them.) + +- [ ] **Step 7: Commit** + +```bash +git add src/types.ts src/utils.ts tests/core/use-ref.test.tsx +git commit -m "feat(utils): useRef honors a cleanup-returning ref" +``` + +--- + +### Task 2: Widen `CanvasProps.ref` to allow a cleanup return + +Lets consumers write `` where `connect` returns a disconnect, without a type error. Pure type change. + +**Files:** +- Modify: `src/canvas.tsx:2` (imports), `src/canvas.tsx:20` (`ref` member) +- Modify: `tests/core/use-ref.test.tsx` (add a compile-time assignability check) + +- [ ] **Step 1: Widen the type** + +In `src/canvas.tsx`, line 2 currently imports `Ref` from `solid-js`. Remove `Ref` from that import (it becomes unused) and import the new type from types. At the top-of-file type imports (line 14 imports from `./types.ts`), add `RefWithCleanup`: + +```ts +// line 2 — drop `type Ref`: +import { onMount, type JSX, type ParentProps } from "solid-js" +// line 14 — add RefWithCleanup to the existing ./types.ts import: +import type { CanvasEventHandlers, Context, Props, ResolvedRenderer, RefWithCleanup } from "./types.ts" +``` + +Change the `ref` member (currently `src/canvas.tsx:20`): + +```ts +// before + ref?: Ref +// after + ref?: RefWithCleanup +``` + +- [ ] **Step 2: Add a compile-time assignability check** + +Append to `tests/core/use-ref.test.tsx`: + +```ts +import type { CanvasProps } from "../../src/canvas.tsx" +import type { Context } from "../../src/types.ts" + +// Compile-time only: a cleanup-returning ref must be assignable to Canvas's ref. +// This line fails to typecheck if CanvasProps.ref was not widened. +const _cleanupRefIsAssignable: CanvasProps["ref"] = (_context: Context) => () => {} +void _cleanupRefIsAssignable +``` + +- [ ] **Step 3: Typecheck** + +Run: `pnpm lint:types` +Expected: exit 0. (If `site/` reports `Cannot find module 'solid-three'`, run `pnpm build` once, then re-run.) + +- [ ] **Step 4: Commit** + +```bash +git add src/canvas.tsx tests/core/use-ref.test.tsx +git commit -m "feat(canvas): widen ref to allow a cleanup-returning callback" +``` + +--- + +### Task 3: `createXR` — state, the reactive listener effect, and `connect` + +The core of the module: signals, the per-renderer `sessionstart`/`sessionend` wiring (including the `setAnimationLoop(null)` double-drive fix), and `connect` (a `Ref` returning a disconnect). + +**Files:** +- Create: `src/create-xr.tsx` +- Create: `tests/core/create-xr.test.tsx` + +- [ ] **Step 1: Write the test file scaffold + failing tests** + +Create `tests/core/create-xr.test.tsx`: + +```tsx +import { createRoot, createSignal } from "solid-js" +import { afterEach, describe, expect, it, vi } from "vitest" +import { createXR } from "../../src/create-xr.tsx" +import type { Context } from "../../src/types.ts" + +/* -------------------------------- fakes -------------------------------- */ + +// three's EventDispatcher dispatches plain `{ type }` objects (not DOM Events), +// and exposes add/removeEventListener — mirror that, not DOM EventTarget. +function makeFakeXR() { + const listeners: Record void>> = {} + return { + enabled: false, + isPresenting: false, + setSession: vi.fn(async (_session: XRSession) => {}), + addEventListener: vi.fn((type: string, l: (e: { type: string }) => void) => { + ;(listeners[type] ??= new Set()).add(l) + }), + removeEventListener: vi.fn((type: string, l: (e: { type: string }) => void) => { + listeners[type]?.delete(l) + }), + dispatch(type: string) { + listeners[type]?.forEach(l => l({ type })) + }, + } +} + +function makeFakeGl(xr = makeFakeXR()) { + return { xr, setAnimationLoop: vi.fn() } +} + +type FakeGl = ReturnType + +function makeFakeContext(gl: FakeGl = makeFakeGl()) { + return { gl, render: vi.fn() } as unknown as Context & { gl: FakeGl; render: ReturnType } +} + +function makeFakeSession() { + return { end: vi.fn(async () => {}) } as unknown as XRSession +} + +// createXR owns a reactive effect, so it must run in an owner. Keep the root +// alive across async enter() and return an explicit dispose. +function renderXR() { + let xr!: ReturnType + const dispose = createRoot(d => { + xr = createXR() + return d + }) + return { xr, dispose } +} + +// Stub navigator.xr (Chromium test runner has no WebXR). +function setFakeNavigatorXR(requestSession = vi.fn(async () => makeFakeSession())) { + const xr = { requestSession, isSessionSupported: vi.fn(async () => true) } + Object.defineProperty(navigator, "xr", { value: xr, configurable: true, writable: true }) + return xr +} + +afterEach(() => { + Reflect.deleteProperty(navigator as unknown as Record, "xr") +}) + +/* ------------------------------- tests --------------------------------- */ + +describe("createXR — state & wiring", () => { + it("tracks isPresenting from sessionstart/sessionend", () => { + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + expect(xr.isPresenting()).toBe(false) + ctx.gl.xr.dispatch("sessionstart") + expect(xr.isPresenting()).toBe(true) + ctx.gl.xr.dispatch("sessionend") + expect(xr.isPresenting()).toBe(false) + + dispose() + }) + + it("nulls the animation loop and clears enabled on sessionend (no post-exit double render)", () => { + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + ctx.gl.xr.dispatch("sessionend") + expect(ctx.gl.setAnimationLoop).toHaveBeenCalledWith(null) + expect(ctx.gl.xr.enabled).toBe(false) + + dispose() + }) + + it("re-attaches listeners on renderer swap and detaches the old", () => { + const first = makeFakeGl() + const second = makeFakeGl() + const [gl, setGl] = createSignal(first) + const ctx = { + get gl() { + return gl() + }, + render: vi.fn(), + } as unknown as Context + const { xr, dispose } = renderXR() + xr.connect(ctx) + + expect(first.xr.addEventListener).toHaveBeenCalledWith("sessionstart", expect.any(Function)) + + setGl(second) + expect(first.xr.removeEventListener).toHaveBeenCalledWith("sessionstart", expect.any(Function)) + expect(second.xr.addEventListener).toHaveBeenCalledWith("sessionstart", expect.any(Function)) + + dispose() + }) + + it("does not crash for an xr manager lacking addEventListener", () => { + const gl = { xr: { enabled: false }, setAnimationLoop: vi.fn() } + const ctx = { gl, render: vi.fn() } as unknown as Context + const { xr, dispose } = renderXR() + expect(() => xr.connect(ctx)).not.toThrow() + dispose() + }) + + it("connect returns a disconnect that clears the context", () => { + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + const disconnect = xr.connect(ctx) + // after disconnect, the listener effect re-runs with no context → detaches + disconnect() + expect(ctx.gl.xr.removeEventListener).toHaveBeenCalledWith("sessionstart", expect.any(Function)) + dispose() + }) +}) +``` + +- [ ] **Step 2: Run — expect failure (module missing)** + +Run: `pnpm exec vitest run tests/core/create-xr.test.tsx` +Expected: FAIL — `Cannot find module '../../src/create-xr.tsx'`. + +- [ ] **Step 3: Implement the module (state + effect + connect only)** + +Create `src/create-xr.tsx`: + +```tsx +import { createRenderEffect, createSignal, onCleanup } from "solid-js" +import type { Context } from "./types.ts" + +/** + * Consumer-owned WebXR entry primitive. Call it in a component body (it owns a + * reactive effect), then connect it to the renderer with + * ``. It absorbs the three sharp edges of the + * decoupled XR contract: + * 1. `setAnimationLoop(render)` is installed BEFORE `setSession` — required by + * WebGPURenderer's XRManager, which snapshots the loop at setSession time. + * 2. `setAnimationLoop(null)` on `sessionend` — stops three re-driving + * `context.render` after exit (which, alongside core's resumed window loop, + * would double-render every frame). + * 3. `requestSession` is called first, with nothing awaited before it, to keep + * the immersive request inside transient user activation. + * + * Built only on the public `Context` and the renderer's `xr` event target — no + * core internals, no WebGL-vs-WebGPU branching. + */ +export function createXR() { + const [context, setContext] = createSignal() + const [presenting, setPresenting] = createSignal(false) + const [session, setSession] = createSignal() + + // `context.gl` is a reactive getter, so this re-runs on renderer swap; + // onCleanup detaches the previous manager's listeners. Clearing context() + // (via connect's disconnect) cascades through here to tear everything down. + createRenderEffect(() => { + const gl = context()?.gl + const xr = gl?.xr + if (!gl || !xr || typeof xr.addEventListener !== "function") return + const onStart = () => setPresenting(true) + const onEnd = () => { + setPresenting(false) + setSession(undefined) + gl.setAnimationLoop(null) // edge 2: stop three re-driving render post-exit + gl.xr.enabled = false + } + xr.addEventListener("sessionstart", onStart) + xr.addEventListener("sessionend", onEnd) + onCleanup(() => { + xr.removeEventListener("sessionstart", onStart) + xr.removeEventListener("sessionend", onEnd) + }) + }) + + function connect(value: Context) { + setContext(value) + return () => setContext(undefined) + } + + return { connect, isPresenting: presenting, session } +} +``` + +Note: `gl.setAnimationLoop` and `gl.xr` are present on `WebGLRenderer` / `WebGPURenderer`. If `lint:types` rejects a member on the resolved renderer union, narrow with a local structural type rather than `any` or `!` (project rule: no non-null assertions) — e.g. read through `const gl = context()?.gl as (Context["gl"] & { setAnimationLoop(cb: ((t: number, f?: XRFrame) => void) | null): void })`. Prefer no cast if the union already carries these members. + +- [ ] **Step 4: Run — expect pass** + +Run: `pnpm exec vitest run tests/core/create-xr.test.tsx` +Expected: all 5 in "state & wiring" PASS. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm lint:types` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/create-xr.tsx tests/core/create-xr.test.tsx +git commit -m "feat(xr): createXR state, sessionstart/sessionend wiring, connect" +``` + +--- + +### Task 4: `createXR.enter` — request-first ordering + escape hatch + +`enter` obtains a session (request, or accept a provided one), then wires it snapshot-safely. + +**Files:** +- Modify: `src/create-xr.tsx` +- Modify: `tests/core/create-xr.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Append a new `describe` block to `tests/core/create-xr.test.tsx`: + +```tsx +describe("createXR — enter", () => { + it("installs setAnimationLoop(render) before setSession (snapshot order)", async () => { + setFakeNavigatorXR() + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + await xr.enter("immersive-vr") + + const loopOrder = ctx.gl.setAnimationLoop.mock.invocationCallOrder[0] + const sessionOrder = ctx.gl.xr.setSession.mock.invocationCallOrder[0] + expect(loopOrder).toBeLessThan(sessionOrder) + expect(ctx.gl.setAnimationLoop).toHaveBeenCalledWith(ctx.render) + expect(ctx.gl.xr.enabled).toBe(true) + + dispose() + }) + + it("calls requestSession before touching the renderer (transient activation)", async () => { + const requestSession = vi.fn(async () => makeFakeSession()) + setFakeNavigatorXR(requestSession) + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + await xr.enter("immersive-vr") + + expect(requestSession.mock.invocationCallOrder[0]).toBeLessThan( + ctx.gl.setAnimationLoop.mock.invocationCallOrder[0], + ) + + dispose() + }) + + it("forwards mode + sessionInit verbatim to requestSession", async () => { + const requestSession = vi.fn(async () => makeFakeSession()) + setFakeNavigatorXR(requestSession) + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + const init = { requiredFeatures: ["local-floor"], optionalFeatures: ["hand-tracking"] } + await xr.enter("immersive-ar", init) + + expect(requestSession).toHaveBeenCalledWith("immersive-ar", init) + + dispose() + }) + + it("enter(session) wires a provided session without calling requestSession", async () => { + const requestSession = vi.fn(async () => makeFakeSession()) + setFakeNavigatorXR(requestSession) + const ctx = makeFakeContext() + const session = makeFakeSession() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + await xr.enter(session) + + expect(requestSession).not.toHaveBeenCalled() + expect(ctx.gl.xr.setSession).toHaveBeenCalledWith(session) + expect(xr.session()).toBe(session) + + dispose() + }) + + it("throws a clear error when called before connect", async () => { + setFakeNavigatorXR() + const { xr, dispose } = renderXR() + await expect(xr.enter("immersive-vr")).rejects.toThrow(/before .*connect/) + dispose() + }) + + it("throws when navigator.xr is unavailable", async () => { + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + // no setFakeNavigatorXR() → navigator.xr is undefined + await expect(xr.enter("immersive-vr")).rejects.toThrow(/WebXR unavailable/) + dispose() + }) +}) +``` + +- [ ] **Step 2: Run — expect failures** + +Run: `pnpm exec vitest run tests/core/create-xr.test.tsx -t "enter"` +Expected: FAIL — `xr.enter` is not a function / `xr.session` may be undefined for the provided-session assertion. + +- [ ] **Step 3: Implement `enter` + `requestSession` + expose `session`** + +In `src/create-xr.tsx`, add inside `createXR` (after `connect`): + +```ts + function requestSession(mode: XRSessionMode, init?: XRSessionInit) { + if (!navigator.xr) { + throw new Error("S3: WebXR unavailable (navigator.xr is undefined)") + } + return navigator.xr.requestSession(mode, init) + } + + async function enter(arg: XRSessionMode | XRSession, init?: XRSessionInit): Promise { + const ctx = context() + if (!ctx) { + throw new Error("S3: createXR().enter() called before connected") + } + const gl = ctx.gl + if (!gl?.xr) { + throw new Error("S3: the active renderer has no xr manager") + } + + // Edge 3: requestSession FIRST — nothing awaited before it (transient activation). + const xrSession = typeof arg === "string" ? await requestSession(arg, init) : arg + + // Edge 1: setAnimationLoop(render) BEFORE setSession (WebGPU snapshots here). + gl.setAnimationLoop(ctx.render) + gl.xr.enabled = true + await gl.xr.setSession(xrSession) + setSession(xrSession) + return xrSession + } +``` + +Update the return statement: + +```ts + return { connect, enter, isPresenting: presenting, session } +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `pnpm exec vitest run tests/core/create-xr.test.tsx -t "enter"` +Expected: all 6 PASS. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm lint:types` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/create-xr.tsx tests/core/create-xr.test.tsx +git commit -m "feat(xr): createXR.enter — request-first ordering + provided-session overload" +``` + +--- + +### Task 5: `createXR.exit` + `isSupported` + +`exit` ends the active session (teardown flows through the `sessionend` listener); `isSupported` is a thin guarded pass-through for button gating. + +**Files:** +- Modify: `src/create-xr.tsx` +- Modify: `tests/core/create-xr.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/core/create-xr.test.tsx`: + +```tsx +describe("createXR — exit & isSupported", () => { + it("exit() ends the active session", async () => { + const session = makeFakeSession() + setFakeNavigatorXR(vi.fn(async () => session)) + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + await xr.enter("immersive-vr") + + await xr.exit() + expect(session.end).toHaveBeenCalledTimes(1) + + dispose() + }) + + it("exit() is a no-op when there is no active session", async () => { + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + await expect(xr.exit()).resolves.toBeUndefined() + dispose() + }) + + it("isSupported delegates to navigator.xr.isSessionSupported", async () => { + const fake = setFakeNavigatorXR() + const { xr, dispose } = renderXR() + expect(await xr.isSupported("immersive-ar")).toBe(true) + expect(fake.isSessionSupported).toHaveBeenCalledWith("immersive-ar") + dispose() + }) + + it("isSupported returns false when navigator.xr is undefined", async () => { + const { xr, dispose } = renderXR() + expect(await xr.isSupported("immersive-vr")).toBe(false) + dispose() + }) +}) +``` + +- [ ] **Step 2: Run — expect failures** + +Run: `pnpm exec vitest run tests/core/create-xr.test.tsx -t "exit & isSupported"` +Expected: FAIL — `xr.exit` / `xr.isSupported` are not functions. + +- [ ] **Step 3: Implement `exit` + `isSupported`** + +In `src/create-xr.tsx`, add inside `createXR` (after `enter`): + +```ts + async function exit() { + // Ending the session fires `sessionend`; the listener effect does all teardown. + await session()?.end() + } + + function isSupported(mode: XRSessionMode): Promise { + return navigator.xr?.isSessionSupported(mode) ?? Promise.resolve(false) + } +``` + +Update the return statement: + +```ts + return { connect, enter, exit, isSupported, isPresenting: presenting, session } +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `pnpm exec vitest run tests/core/create-xr.test.tsx` +Expected: ALL tests in the file PASS. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm lint:types` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/create-xr.tsx tests/core/create-xr.test.tsx +git commit -m "feat(xr): createXR.exit + isSupported" +``` + +--- + +### Task 6: Export `createXR` from the package entry + +**Files:** +- Modify: `src/index.ts` +- Modify: `tests/core/create-xr.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/core/create-xr.test.tsx`: + +```tsx +describe("createXR — public export", () => { + it("is exported from the package entry", async () => { + const entry = await import("../../src/index.ts") + expect(typeof entry.createXR).toBe("function") + }) +}) +``` + +- [ ] **Step 2: Run — expect failure** + +Run: `pnpm exec vitest run tests/core/create-xr.test.tsx -t "public export"` +Expected: FAIL — `entry.createXR` is `undefined`. + +- [ ] **Step 3: Add the export** + +In `src/index.ts`, add (alphabetically near the other `create*` exports — currently `export { createEntity, createT } from "./create-t.tsx"`): + +```ts +export { createXR } from "./create-xr.tsx" +``` + +- [ ] **Step 4: Run — expect pass** + +Run: `pnpm exec vitest run tests/core/create-xr.test.tsx -t "public export"` +Expected: PASS. + +- [ ] **Step 5: Full suite + typecheck + lint** + +Run: `pnpm test` then `pnpm lint:types` then `pnpm lint:code` +Expected: all PASS / exit 0. (The full suite was 151 passing at baseline; expect 151 + the new `use-ref` and `create-xr` tests, 0 failures.) + +- [ ] **Step 6: Commit** + +```bash +git add src/index.ts tests/core/create-xr.test.tsx +git commit -m "feat(xr): export createXR from package entry" +``` + +--- + +## Self-Review + +**Spec coverage:** + +- Public API `connect` / `enter(mode, init)` / `enter(session)` / `exit` / `isPresenting` / `session` / `isSupported` → Tasks 3-5. ✓ +- Edge 1 (setAnimationLoop before setSession) → Task 4, "snapshot order" test. ✓ +- Edge 2 (setAnimationLoop(null) on sessionend) → Task 3, "nulls the animation loop" test. ✓ +- Edge 3 (requestSession first) → Task 4, "transient activation" test. ✓ +- `connect` as a cleanup-returning `Ref` → Task 3 ("connect returns a disconnect") + Tasks 1-2 (the `useRef`/`CanvasProps` plumbing that makes `` honor it). ✓ +- Reactive state from three's `xr` events → Task 3 ("tracks isPresenting"). ✓ +- Renderer swap = reset → Task 3 ("re-attaches on renderer swap"). ✓ +- `forceWebGL` untouched → no task edits renderer/backend logic; nothing to do. ✓ +- Spec test list items 1-7 map to Tasks 3-5; item 8 (Canvas invokes a cleanup-returning ref on unmount) is covered structurally by Task 1 (`useRef` runs the cleanup on dispose) since `create-three.tsx:597` routes Canvas's ref through `useRef` — noted in Task 1's intro. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; every run step states the exact command and expected result. ✓ + +**Type consistency:** `createXR` returns `{ connect, enter, exit, isSupported, isPresenting, session }` — the return statement is updated in Tasks 3 (`connect`, `isPresenting`, `session`), 4 (`enter`), and 5 (`exit`, `isSupported`), each shown in full. `RefWithCleanup` is defined once (Task 1) and consumed in `useRef` (Task 1) and `CanvasProps.ref` (Task 2). Fake helpers (`makeFakeXR`/`makeFakeGl`/`makeFakeContext`/`makeFakeSession`/`renderXR`/`setFakeNavigatorXR`) are defined once in Task 3 step 1 and reused by name in Tasks 4-6. ✓ From b7c126766465601fa65503729f0fd7f9d7c3182c Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 15:35:01 +0200 Subject: [PATCH 11/27] feat(utils): useRef honors a cleanup-returning ref (cherry picked from commit ee763aa29116fb1c3ff0d87ac0b2f032c4268327) --- src/types.ts | 7 +++++++ src/utils.ts | 11 ++++++----- tests/core/use-ref.test.tsx | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 tests/core/use-ref.test.tsx diff --git a/src/types.ts b/src/types.ts index 3aaf270d..f38d248a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,13 @@ import type { Measure } from "./utils/use-measure.ts" export type AccessorMaybe = T | Accessor export type PromiseMaybe = T | Promise +/** + * A ref that is a value sink, a callback, or a callback returning a cleanup + * (the React-19 cleanup-callback-ref shape). The cleanup runs when the ref's + * reactive owner disposes or the ref value changes. + */ +export type RefWithCleanup = T | ((value: T) => void | (() => void)) + export type ClassInstance = T & { constructor: Function } /** Generic constructor. Returns instance of given type. Defaults to any. */ diff --git a/src/utils.ts b/src/utils.ts index d7d43959..b82d3788 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import type { Accessor, Context, JSX } from "solid-js" -import { createRenderEffect, mergeProps, onCleanup, type Ref } from "solid-js" +import { createRenderEffect, mergeProps, onCleanup } from "solid-js" import { type BufferGeometry, Camera, @@ -22,6 +22,7 @@ import type { LoaderUrl, Meta, Prettify, + RefWithCleanup, Renderer, RendererLike, } from "./types.ts" @@ -477,16 +478,16 @@ export async function load< /* */ /**********************************************************************************/ -export function useRef(props: { ref?: Ref }, value: T | Accessor) { +export function useRef(props: { ref?: RefWithCleanup }, value: T | Accessor) { createRenderEffect(() => { const result = typeof value === "function" - ? // @ts-expect-error + ? // @ts-expect-error — T may itself be callable; the Accessor branch is intended value() : value if (typeof props.ref === "function") { - // @ts-expect-error - props.ref(result) + const cleanup = (props.ref as (value: T) => void | (() => void))(result) + if (typeof cleanup === "function") onCleanup(cleanup) } else { props.ref = result } diff --git a/tests/core/use-ref.test.tsx b/tests/core/use-ref.test.tsx new file mode 100644 index 00000000..3132e4d2 --- /dev/null +++ b/tests/core/use-ref.test.tsx @@ -0,0 +1,35 @@ +import { createRoot } from "solid-js" +import { describe, expect, it, vi } from "vitest" +import { useRef } from "../../src/utils.ts" + +describe("useRef", () => { + it("invokes a function ref with the value", () => { + const ref = vi.fn() + createRoot(dispose => { + useRef({ ref }, 42) + dispose() + }) + expect(ref).toHaveBeenCalledWith(42) + }) + + it("runs a ref's returned cleanup on dispose", () => { + const cleanup = vi.fn() + const ref = vi.fn(() => cleanup) + const dispose = createRoot(d => { + useRef({ ref }, 42) + return d + }) + expect(cleanup).not.toHaveBeenCalled() + dispose() + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + it("assigns to a non-function ref slot", () => { + const props: { ref?: number } = {} + createRoot(dispose => { + useRef(props, 7) + dispose() + }) + expect(props.ref).toBe(7) + }) +}) From 1a4ab86a16654492ff7391d96bb1cf8eab2732df Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 15:36:00 +0200 Subject: [PATCH 12/27] feat(canvas): widen ref to allow a cleanup-returning callback (cherry picked from commit a6e312f03ccc20a19d0cc567eb887d30fc2b6738) --- src/canvas.tsx | 6 +++--- tests/core/use-ref.test.tsx | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/canvas.tsx b/src/canvas.tsx index 53b9f565..88a3b098 100644 --- a/src/canvas.tsx +++ b/src/canvas.tsx @@ -1,5 +1,5 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" -import { onMount, type JSX, type ParentProps, type Ref } from "solid-js" +import { onMount, type JSX, type ParentProps } from "solid-js" import { Camera, OrthographicCamera, @@ -11,13 +11,13 @@ import { } from "three" import { createThree } from "./create-three.tsx" import type { EventRaycaster } from "./raycasters.tsx" -import type { CanvasEventHandlers, Context, Props, ResolvedRenderer } from "./types.ts" +import type { CanvasEventHandlers, Context, Props, RefWithCleanup, ResolvedRenderer } from "./types.ts" /** * Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene. */ export interface CanvasProps extends ParentProps> { - ref?: Ref + ref?: RefWithCleanup class?: string /** Configuration for the camera used in the scene. */ camera?: Partial | Props> | Camera diff --git a/tests/core/use-ref.test.tsx b/tests/core/use-ref.test.tsx index 3132e4d2..56c21631 100644 --- a/tests/core/use-ref.test.tsx +++ b/tests/core/use-ref.test.tsx @@ -1,7 +1,14 @@ import { createRoot } from "solid-js" import { describe, expect, it, vi } from "vitest" +import type { CanvasProps } from "../../src/canvas.tsx" +import type { Context } from "../../src/types.ts" import { useRef } from "../../src/utils.ts" +// Compile-time only: a cleanup-returning ref must be assignable to Canvas's ref. +// This line fails to typecheck if CanvasProps.ref was not widened. +const _cleanupRefIsAssignable: CanvasProps["ref"] = (_context: Context) => () => {} +void _cleanupRefIsAssignable + describe("useRef", () => { it("invokes a function ref with the value", () => { const ref = vi.fn() From 2bf302b15a4fcaecc48558db1400bbb43b8a493d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 15:39:17 +0200 Subject: [PATCH 13/27] feat(xr): createXR state, sessionstart/sessionend wiring, connect (cherry picked from commit 16cb7da56e4c0325d318040ee3e158e444b7407b) --- src/create-xr.tsx | 76 +++++++++++++++++++ tests/core/create-xr.test.tsx | 134 ++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/create-xr.tsx create mode 100644 tests/core/create-xr.test.tsx diff --git a/src/create-xr.tsx b/src/create-xr.tsx new file mode 100644 index 00000000..2f809d86 --- /dev/null +++ b/src/create-xr.tsx @@ -0,0 +1,76 @@ +import { createRenderEffect, createSignal, onCleanup } from "solid-js" +import type { Context } from "./types.ts" + +/** + * The structural slice of a renderer that `createXR` drives. WebGLRenderer's + * `WebXRManager` and WebGPURenderer's `XRManager` expose the same runtime XR + * surface but with differently-typed event maps, so their union is not callable + * (`addEventListener` has no compatible combined signature). Narrowing to this + * slice sidesteps that — the same tactic `create-three.tsx` uses for its own + * `sessionend` listener (`gl as { xr?: EventTarget }`). + */ +type XRRenderer = { + setAnimationLoop(callback: ((time: number, frame?: XRFrame) => void) | null): void + xr: { + enabled: boolean + setSession(session: XRSession): Promise + addEventListener(type: string, listener: () => void): void + removeEventListener(type: string, listener: () => void): void + } +} + +function asXRRenderer(gl: Context["gl"]): XRRenderer { + return gl as unknown as XRRenderer +} + +/** + * Consumer-owned WebXR entry primitive. Call it in a component body (it owns a + * reactive effect), then connect it to the renderer with + * ``. It absorbs the three sharp edges of the + * decoupled XR contract: + * 1. `setAnimationLoop(render)` is installed BEFORE `setSession` — required by + * WebGPURenderer's XRManager, which snapshots the loop at setSession time. + * 2. `setAnimationLoop(null)` on `sessionend` — stops three re-driving + * `context.render` after exit (which, alongside core's resumed window loop, + * would double-render every frame). + * 3. `requestSession` is called first, with nothing awaited before it, to keep + * the immersive request inside transient user activation. + * + * Built only on the public `Context` and the renderer's `xr` event target — no + * core internals, no WebGL-vs-WebGPU branching. + */ +export function createXR() { + const [context, setContext] = createSignal() + const [presenting, setPresenting] = createSignal(false) + const [session, setSession] = createSignal() + + // `context.gl` is a reactive getter, so this re-runs on renderer swap; + // onCleanup detaches the previous manager's listeners. Clearing context() + // (via connect's disconnect) cascades through here to tear everything down. + createRenderEffect(() => { + const ctx = context() + const gl = ctx ? asXRRenderer(ctx.gl) : undefined + const xr = gl?.xr + if (!gl || !xr || typeof xr.addEventListener !== "function") return + const onStart = () => setPresenting(true) + const onEnd = () => { + setPresenting(false) + setSession(undefined) + gl.setAnimationLoop(null) // edge 2: stop three re-driving render post-exit + gl.xr.enabled = false + } + xr.addEventListener("sessionstart", onStart) + xr.addEventListener("sessionend", onEnd) + onCleanup(() => { + xr.removeEventListener("sessionstart", onStart) + xr.removeEventListener("sessionend", onEnd) + }) + }) + + function connect(value: Context) { + setContext(value) + return () => setContext(undefined) + } + + return { connect, isPresenting: presenting, session } +} diff --git a/tests/core/create-xr.test.tsx b/tests/core/create-xr.test.tsx new file mode 100644 index 00000000..b63605f7 --- /dev/null +++ b/tests/core/create-xr.test.tsx @@ -0,0 +1,134 @@ +import { createRoot, createSignal } from "solid-js" +import { afterEach, describe, expect, it, vi } from "vitest" +import { createXR } from "../../src/create-xr.tsx" +import type { Context } from "../../src/types.ts" + +/* -------------------------------- fakes -------------------------------- */ + +// three's EventDispatcher dispatches plain `{ type }` objects (not DOM Events), +// and exposes add/removeEventListener — mirror that, not DOM EventTarget. +function makeFakeXR() { + const listeners: Record void>> = {} + return { + enabled: false, + isPresenting: false, + setSession: vi.fn(async (_session: XRSession) => {}), + addEventListener: vi.fn((type: string, l: (e: { type: string }) => void) => { + ;(listeners[type] ??= new Set()).add(l) + }), + removeEventListener: vi.fn((type: string, l: (e: { type: string }) => void) => { + listeners[type]?.delete(l) + }), + dispatch(type: string) { + listeners[type]?.forEach(l => l({ type })) + }, + } +} + +function makeFakeGl(xr = makeFakeXR()) { + return { xr, setAnimationLoop: vi.fn() } +} + +type FakeGl = ReturnType + +function makeFakeContext(gl: FakeGl = makeFakeGl()) { + return { gl, render: vi.fn() } as unknown as Context & { + gl: FakeGl + render: ReturnType + } +} + +function makeFakeSession() { + return { end: vi.fn(async () => {}) } as unknown as XRSession +} + +// createXR owns a reactive effect, so it must run in an owner. Keep the root +// alive across async enter() and return an explicit dispose. +function renderXR() { + let xr!: ReturnType + const dispose = createRoot(d => { + xr = createXR() + return d + }) + return { xr, dispose } +} + +// Stub navigator.xr (Chromium test runner has no WebXR). +function setFakeNavigatorXR(requestSession = vi.fn(async () => makeFakeSession())) { + const xr = { requestSession, isSessionSupported: vi.fn(async () => true) } + Object.defineProperty(navigator, "xr", { value: xr, configurable: true, writable: true }) + return xr +} + +afterEach(() => { + Reflect.deleteProperty(navigator as unknown as Record, "xr") +}) + +/* ------------------------------- tests --------------------------------- */ + +describe("createXR — state & wiring", () => { + it("tracks isPresenting from sessionstart/sessionend", () => { + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + expect(xr.isPresenting()).toBe(false) + ctx.gl.xr.dispatch("sessionstart") + expect(xr.isPresenting()).toBe(true) + ctx.gl.xr.dispatch("sessionend") + expect(xr.isPresenting()).toBe(false) + + dispose() + }) + + it("nulls the animation loop and clears enabled on sessionend (no post-exit double render)", () => { + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + ctx.gl.xr.dispatch("sessionend") + expect(ctx.gl.setAnimationLoop).toHaveBeenCalledWith(null) + expect(ctx.gl.xr.enabled).toBe(false) + + dispose() + }) + + it("re-attaches listeners on renderer swap and detaches the old", () => { + const first = makeFakeGl() + const second = makeFakeGl() + const [gl, setGl] = createSignal(first) + const ctx = { + get gl() { + return gl() + }, + render: vi.fn(), + } as unknown as Context + const { xr, dispose } = renderXR() + xr.connect(ctx) + + expect(first.xr.addEventListener).toHaveBeenCalledWith("sessionstart", expect.any(Function)) + + setGl(second) + expect(first.xr.removeEventListener).toHaveBeenCalledWith("sessionstart", expect.any(Function)) + expect(second.xr.addEventListener).toHaveBeenCalledWith("sessionstart", expect.any(Function)) + + dispose() + }) + + it("does not crash for an xr manager lacking addEventListener", () => { + const gl = { xr: { enabled: false }, setAnimationLoop: vi.fn() } + const ctx = { gl, render: vi.fn() } as unknown as Context + const { xr, dispose } = renderXR() + expect(() => xr.connect(ctx)).not.toThrow() + dispose() + }) + + it("connect returns a disconnect that clears the context", () => { + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + const disconnect = xr.connect(ctx) + disconnect() + expect(ctx.gl.xr.removeEventListener).toHaveBeenCalledWith("sessionstart", expect.any(Function)) + dispose() + }) +}) From fa885654b2aac7e9ef158609b0ff94df18e9e357 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 15:41:17 +0200 Subject: [PATCH 14/27] =?UTF-8?q?feat(xr):=20createXR.enter=20=E2=80=94=20?= =?UTF-8?q?request-first=20ordering=20+=20provided-session=20overload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 2c6c5335d987d5976857b38856a52354de7277ae) --- src/create-xr.tsx | 30 ++++++++++++- tests/core/create-xr.test.tsx | 85 +++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/create-xr.tsx b/src/create-xr.tsx index 2f809d86..628d8951 100644 --- a/src/create-xr.tsx +++ b/src/create-xr.tsx @@ -72,5 +72,33 @@ export function createXR() { return () => setContext(undefined) } - return { connect, isPresenting: presenting, session } + function requestSession(mode: XRSessionMode, init?: XRSessionInit) { + if (!navigator.xr) { + throw new Error("S3: WebXR unavailable (navigator.xr is undefined)") + } + return navigator.xr.requestSession(mode, init) + } + + async function enter(arg: XRSessionMode | XRSession, init?: XRSessionInit): Promise { + const ctx = context() + if (!ctx) { + throw new Error("S3: createXR().enter() called before connected") + } + const gl = asXRRenderer(ctx.gl) + if (!gl.xr) { + throw new Error("S3: the active renderer has no xr manager") + } + + // Edge 3: requestSession FIRST — nothing awaited before it (transient activation). + const xrSession = typeof arg === "string" ? await requestSession(arg, init) : arg + + // Edge 1: setAnimationLoop(render) BEFORE setSession (WebGPU snapshots here). + gl.setAnimationLoop(ctx.render) + gl.xr.enabled = true + await gl.xr.setSession(xrSession) + setSession(xrSession) + return xrSession + } + + return { connect, enter, isPresenting: presenting, session } } diff --git a/tests/core/create-xr.test.tsx b/tests/core/create-xr.test.tsx index b63605f7..4b191cd9 100644 --- a/tests/core/create-xr.test.tsx +++ b/tests/core/create-xr.test.tsx @@ -132,3 +132,88 @@ describe("createXR — state & wiring", () => { dispose() }) }) + +describe("createXR — enter", () => { + it("installs setAnimationLoop(render) before setSession (snapshot order)", async () => { + setFakeNavigatorXR() + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + await xr.enter("immersive-vr") + + const loopOrder = ctx.gl.setAnimationLoop.mock.invocationCallOrder[0] + const sessionOrder = ctx.gl.xr.setSession.mock.invocationCallOrder[0] + expect(loopOrder).toBeLessThan(sessionOrder) + expect(ctx.gl.setAnimationLoop).toHaveBeenCalledWith(ctx.render) + expect(ctx.gl.xr.enabled).toBe(true) + + dispose() + }) + + it("calls requestSession before touching the renderer (transient activation)", async () => { + const requestSession = vi.fn(async () => makeFakeSession()) + setFakeNavigatorXR(requestSession) + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + await xr.enter("immersive-vr") + + expect(requestSession.mock.invocationCallOrder[0]).toBeLessThan( + ctx.gl.setAnimationLoop.mock.invocationCallOrder[0], + ) + + dispose() + }) + + it("forwards mode + sessionInit verbatim to requestSession", async () => { + const requestSession = vi.fn(async () => makeFakeSession()) + setFakeNavigatorXR(requestSession) + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + const init = { requiredFeatures: ["local-floor"], optionalFeatures: ["hand-tracking"] } + await xr.enter("immersive-ar", init) + + expect(requestSession).toHaveBeenCalledWith("immersive-ar", init) + + dispose() + }) + + it("enter(session) wires a provided session without calling requestSession", async () => { + const requestSession = vi.fn(async () => makeFakeSession()) + setFakeNavigatorXR(requestSession) + const ctx = makeFakeContext() + const session = makeFakeSession() + const { xr, dispose } = renderXR() + xr.connect(ctx) + + await xr.enter(session) + + expect(requestSession).not.toHaveBeenCalled() + expect(ctx.gl.xr.setSession).toHaveBeenCalledWith(session) + expect(xr.session()).toBe(session) + + dispose() + }) + + it("throws a clear error when called before connect", async () => { + setFakeNavigatorXR() + const { xr, dispose } = renderXR() + await expect(xr.enter("immersive-vr")).rejects.toThrow(/before .*connect/) + dispose() + }) + + it("throws when navigator.xr is unavailable", async () => { + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + // The Chromium test runner has a native navigator.xr; shadow it with + // undefined to exercise the unavailability guard. + Object.defineProperty(navigator, "xr", { value: undefined, configurable: true }) + await expect(xr.enter("immersive-vr")).rejects.toThrow(/WebXR unavailable/) + dispose() + }) +}) From 5cce3d39e6e9b8073b5e2ef971cecee728edd1ab Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 15:42:19 +0200 Subject: [PATCH 15/27] feat(xr): createXR.exit + isSupported (cherry picked from commit fcaef1967c5af5cd08289726e0bc9ef80e675d37) --- src/create-xr.tsx | 11 +++++++++- tests/core/create-xr.test.tsx | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/create-xr.tsx b/src/create-xr.tsx index 628d8951..520018ea 100644 --- a/src/create-xr.tsx +++ b/src/create-xr.tsx @@ -100,5 +100,14 @@ export function createXR() { return xrSession } - return { connect, enter, isPresenting: presenting, session } + async function exit() { + // Ending the session fires `sessionend`; the listener effect does all teardown. + await session()?.end() + } + + function isSupported(mode: XRSessionMode): Promise { + return navigator.xr?.isSessionSupported(mode) ?? Promise.resolve(false) + } + + return { connect, enter, exit, isSupported, isPresenting: presenting, session } } diff --git a/tests/core/create-xr.test.tsx b/tests/core/create-xr.test.tsx index 4b191cd9..e1341852 100644 --- a/tests/core/create-xr.test.tsx +++ b/tests/core/create-xr.test.tsx @@ -217,3 +217,43 @@ describe("createXR — enter", () => { dispose() }) }) + +describe("createXR — exit & isSupported", () => { + it("exit() ends the active session", async () => { + const session = makeFakeSession() + setFakeNavigatorXR(vi.fn(async () => session)) + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + await xr.enter("immersive-vr") + + await xr.exit() + expect(session.end).toHaveBeenCalledTimes(1) + + dispose() + }) + + it("exit() is a no-op when there is no active session", async () => { + const ctx = makeFakeContext() + const { xr, dispose } = renderXR() + xr.connect(ctx) + await expect(xr.exit()).resolves.toBeUndefined() + dispose() + }) + + it("isSupported delegates to navigator.xr.isSessionSupported", async () => { + const fake = setFakeNavigatorXR() + const { xr, dispose } = renderXR() + expect(await xr.isSupported("immersive-ar")).toBe(true) + expect(fake.isSessionSupported).toHaveBeenCalledWith("immersive-ar") + dispose() + }) + + it("isSupported returns false when navigator.xr is undefined", async () => { + const { xr, dispose } = renderXR() + // Native navigator.xr exists in the runner; shadow it with undefined. + Object.defineProperty(navigator, "xr", { value: undefined, configurable: true }) + expect(await xr.isSupported("immersive-vr")).toBe(false) + dispose() + }) +}) From d6915261f9413fc85aa520d84c86e45685c4b589 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 15:44:02 +0200 Subject: [PATCH 16/27] feat(xr): export createXR from package entry (cherry picked from commit cb61f7a04333100dc400bc0dc777459a6490775b) --- src/index.ts | 1 + tests/core/create-xr.test.tsx | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/index.ts b/src/index.ts index a9ecfa9e..6bffd072 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export { Canvas, type CanvasProps } from "./canvas.tsx" export { Entity, Portal, Resource } from "./components.tsx" export { $S3C } from "./constants.ts" export { createEntity, createT } from "./create-t.tsx" +export { createXR } from "./create-xr.tsx" export { useFrame, useLoader, useThree } from "./hooks.ts" export { useProps } from "./props.ts" export * from "./raycasters.tsx" diff --git a/tests/core/create-xr.test.tsx b/tests/core/create-xr.test.tsx index e1341852..d80fdf0c 100644 --- a/tests/core/create-xr.test.tsx +++ b/tests/core/create-xr.test.tsx @@ -257,3 +257,10 @@ describe("createXR — exit & isSupported", () => { dispose() }) }) + +describe("createXR — public export", () => { + it("is exported from the package entry", async () => { + const entry = await import("../../src/index.ts") + expect(typeof entry.createXR).toBe("function") + }) +}) From 9520f1d9ddae2a081167bcb761fc283f369ea4b3 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 16:11:36 +0200 Subject: [PATCH 17/27] docs(xr): document createXR (README + API site) - New API page site/src/routes/api/hooks/create-xr.mdx + sidebar entry - README: createXR section, feature bullet, TOC; fix stale Context.render signature and remove the removed Context.xr field - Canvas ref documented as a cleanup-returning Context ref (README + canvas.mdx) - use-three WebXR section now points at createXR as the recommended path (cherry picked from commit 412c73d9164c6659a4647b52718837a5085b614d) --- README.md | 92 ++++++++++++++++++- site/src/routes/api/components/canvas.mdx | 3 +- site/src/routes/api/hooks/create-xr.mdx | 107 ++++++++++++++++++++++ site/src/routes/api/hooks/use-three.mdx | 8 +- site/vite.config.ts | 1 + 5 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 site/src/routes/api/hooks/create-xr.mdx diff --git a/README.md b/README.md index 379b1d43..efc6abec 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - **Declarative `three.js` Components**: Utilize `three.js` objects as JSX components. - **Reactive Prop Updates**: Properties of 3D objects update reactively, promoting efficient re-renders. - **Integrated Animation Loop**: `useFrame` hook allows for easy animations. +- **WebXR (VR & AR)**: `createXR` enters and exits immersive sessions on both `WebGLRenderer` and `WebGPURenderer`. - **Comprehensive Event System**: Enhanced event handling with support for `three.js` pointer and mouse events. - **Extensible and Customizable**: Easily extendable with additional `three.js` entities or custom behaviors. - **Optimized for `solid-js`**: Leverages `solid-js`' fine-grained reactivity for optimal performance. @@ -27,6 +28,7 @@ 4. [Hooks](#hooks) - [useThree](#usethree) - [useFrame](#useframe) + - [createXR](#createxr) - [useProps](#useprops) 5. [Utilities](#utilities) - [Raycasters](#raycasters) @@ -114,6 +116,7 @@ The `Canvas` component initializes the `three.js` rendering context and acts as - `"never"`: Disables automatic rendering - **style**: Custom CSS styles for the canvas container. - **class**: CSS class names for the canvas container. +- **ref**: Receives the scene's [`Context`](#usethree) once the renderer is created, so code outside `` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts (the React-19 cleanup-callback-ref shape) — [`createXR`](#createxr) uses this to release the renderer when the scene goes away. - **Event handlers**: All event handlers are supported on the Canvas component, allowing you to handle events that bubble through the entire scene (e.g., `onClick`, `onPointerMove`, `onClickMissed`, etc.)
@@ -485,10 +488,9 @@ const camera = useThree(ctx => ctx.camera) - **gl** (`Renderer`): The active renderer — `WebGLRenderer | WebGPURenderer | RendererLike` by default. Narrow to a concrete type project-wide via [Register augmentation](#narrowing-the-renderer-type-project-wide). - **raycaster** (`Raycaster`): The current raycaster used for pointer events. - **setRaycaster** (`(raycaster: Raycaster) => () => void`): A setter-function for setting the current raycaster. -- **render** (`(delta: number) => void`): Function to manually trigger a render. +- **render** (`(timestamp: number, frame?: XRFrame) => void`): Drives a single frame — runs the registered `useFrame` callbacks, then renders the scene. The optional `frame` is forwarded to those callbacks; an active WebXR session passes it on each immersive frame. - **requestRender** (`() => void`): Function to request a render on the next frame. - **scene** (`Scene`): The root scene. -- **xr** (`{ connect: () => void; disconnect: () => void }`): WebXR connection management. **Camera and Raycaster Stack System:** @@ -593,6 +595,92 @@ useFrame( ``` +### createXR + +Enters and exits WebXR sessions (VR and AR) for the scene's renderer. The same code works on both `WebGLRenderer` and `WebGPURenderer`. + +Unlike the `use*` hooks, `createXR` is called **outside** `` — typically in the same component as your "Enter XR" button, which lives in the DOM rather than the 3D scene. You connect it to the renderer with ``. `createXR` owns a reactive effect, so call it in a component body (like `createSignal`). + +It exists because entering a session correctly has a few ordering rules that are easy to get wrong — the animation loop must be installed before the session starts, the immersive request must run synchronously inside the button's click handler, and the loop must be released on exit. `createXR` handles all of them so you don't have to. + +**Returns** an object with: + +- **connect** (`(context: Context) => () => void`): A ref for ``. Hands the renderer to `createXR` and returns a cleanup that runs on unmount. +- **enter** (`(mode, sessionInit?) => Promise`): Requests a session and enters it. `mode` is `"immersive-vr"` / `"immersive-ar"` / `"inline"`; `sessionInit` is passed straight through to `navigator.xr.requestSession`. Call it directly from a user-gesture handler (a click), so the immersive request keeps its user activation. You may also pass an `XRSession` you created yourself, to skip the request and just wire it. +- **exit** (`() => Promise`): Ends the active session. +- **isPresenting** (`() => boolean`): Reactive — `true` while a session is presenting. +- **session** (`() => XRSession | undefined`): Reactive — the active session, or `undefined`. +- **isSupported** (`(mode) => Promise`): Whether the device supports a session mode. Use it to decide whether to show the button; don't `await` it before calling `enter`. + +
+Typescript Interface + +```tsx +function createXR(): { + connect: (context: Context) => () => void + enter: (mode: XRSessionMode, sessionInit?: XRSessionInit) => Promise + enter: (session: XRSession) => Promise + exit: () => Promise + isPresenting: () => boolean + session: () => XRSession | undefined + isSupported: (mode: XRSessionMode) => Promise +} +``` + +
+ +**Usage:** + +```tsx +import { Canvas, createXR } from "solid-three" +import { Show } from "solid-js" + +function App() { + const xr = createXR() + + return ( + <> + {/* The button is DOM, outside the Canvas. onClick is the user gesture. */} + + + + + + + + + + ) +} +``` + +**AR with feature descriptors, gated on support:** + +```tsx +import { createResource, Show } from "solid-js" + +const xr = createXR() +const [supported] = createResource(() => xr.isSupported("immersive-ar")) + + + + +``` + +> **Reading controller and hand poses:** you don't need `createXR` inside the scene. The third argument to [`useFrame`](#useframe) is the live `XRFrame` during a session — read poses from there. + +> **`WebGPURenderer` note:** on three.js ≤ r184, driving a WebXR session through the WebGPU backend requires a WebGL2 fallback. `createXR` itself is backend-agnostic; configure the renderer at the `` level. + + ### useLoader Manages asynchronous resource loading, such as textures or models, and integrates with `solid-js`' reactivity system. This hook can be used with Solid's `` to handle loading states. diff --git a/site/src/routes/api/components/canvas.mdx b/site/src/routes/api/components/canvas.mdx index 2379d2ef..f1815e4b 100644 --- a/site/src/routes/api/components/canvas.mdx +++ b/site/src/routes/api/components/canvas.mdx @@ -22,6 +22,7 @@ title: Canvas | `fallback` | `JSX.Element` | — | Shown while content loads asynchronously. | | `style` | `JSX.CSSProperties` | — | CSS for the canvas container. | | `class` | `string` | — | CSS class for the canvas container. | +| `ref` | `RefWithCleanup` | — | Receives the [`Context`](/api/hooks/use-three) once the renderer is created, so code outside `` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts (the React-19 cleanup-callback-ref shape); [`createXR`](/api/hooks/create-xr) uses this. | | event handlers | `Partial` | — | Any [event handler](/api/events/overview), firing after the event bubbles through the whole scene (e.g. `onClick`, `onClickMissed`). |
@@ -29,7 +30,7 @@ title: Canvas ```tsx interface CanvasProps extends ParentProps> { - ref?: Ref + ref?: RefWithCleanup // Context | ((context: Context) => void | (() => void)) camera?: Partial | Props> | Camera fallback?: JSX.Element gl?: diff --git a/site/src/routes/api/hooks/create-xr.mdx b/site/src/routes/api/hooks/create-xr.mdx new file mode 100644 index 00000000..1e7397d5 --- /dev/null +++ b/site/src/routes/api/hooks/create-xr.mdx @@ -0,0 +1,107 @@ +--- +title: createXR +--- + +# createXR + +`createXR` enters and exits WebXR sessions — VR and AR — for the scene's renderer. The same code works on both `WebGLRenderer` and `WebGPURenderer`. + +Unlike the `use*` hooks, you call `createXR` **outside** ``. An "Enter XR" button is a DOM element, not part of the 3D scene, so the control that triggers a session lives next to that button — outside the canvas. You connect it to the renderer with ``. Because `createXR` owns a reactive effect, call it in a component body, the same way you'd call `createSignal`. + +## Signature + +```tsx +const xr = createXR() +``` + +## Returns + +| Property | Type | Description | +| --- | --- | --- | +| `connect` | `(context: Context) => () => void` | A ref for ``. Hands the renderer to `createXR`, and returns a cleanup that runs when the Canvas unmounts. | +| `enter` | `(mode, sessionInit?) => Promise` | Requests a session and enters it. `mode` is `"immersive-vr"`, `"immersive-ar"`, or `"inline"`; `sessionInit` passes straight to `navigator.xr.requestSession`. You may instead pass an `XRSession` you already created, to skip the request and just wire it. | +| `exit` | `() => Promise` | Ends the active session. | +| `isPresenting` | `() => boolean` | Reactive — `true` while a session is presenting. | +| `session` | `() => XRSession \| undefined` | Reactive — the active session, or `undefined`. | +| `isSupported` | `(mode: XRSessionMode) => Promise` | Whether the device supports a session mode. Use it to decide whether to show the button. | + +## Behavior + +`createXR` exists because entering a session has a few ordering rules that are easy to get wrong, and silently break rendering when you do. It handles all of them: + +- **It installs the render loop before starting the session.** `WebGPURenderer` captures the animation loop at the moment the session starts, so the loop has to be in place first. `createXR` installs it before calling `setSession`. +- **It requests the session synchronously.** Immersive requests need [transient user activation](https://developer.mozilla.org/en-US/docs/Web/API/XRSystem/requestSession) — they have to run inside the user-gesture handler. So call `enter` directly from a button's `onClick`, and don't `await` anything (such as `isSupported`) before it. +- **It releases the loop on exit.** When the session ends, `createXR` hands the frame loop back to solid-three's own window loop, so the flat canvas keeps rendering. + +While a session is presenting, solid-three's window loop steps aside automatically — the renderer's own loop, driven by the headset, owns rendering. `createXR` depends only on the renderer's public `xr` event target, so it has no `WebGLRenderer`-versus-`WebGPURenderer` branches. + +## Examples + +A VR scene with a DOM "Enter VR" button: + +```tsx +import { Canvas, createXR } from "solid-three" +import { Show } from "solid-js" + +function App() { + const xr = createXR() + + return ( + <> + {/* The button is DOM, outside the Canvas. onClick is the user gesture. */} + + + + + + + + + + ) +} +``` + +AR with feature descriptors, shown only when the device supports it: + +```tsx +import { createResource, Show } from "solid-js" + +const xr = createXR() +const [supported] = createResource(() => xr.isSupported("immersive-ar")) + + + + +``` + +Reading controller and hand poses — no `createXR` needed inside the scene. The third argument to [`useFrame`](/api/hooks/use-frame) is the live `XRFrame` during a session: + +```tsx +function Player() { + useFrame((context, delta, frame) => { + if (!frame) return // only present during a session + // read XRFrame poses, move the rig, etc. + }) + return null +} +``` + +## WebGPU note + +`createXR` is renderer-agnostic. On three.js ≤ r184, driving a WebXR session through the WebGPU backend needs a WebGL2 fallback — construct the renderer with `{ forceWebGL: true }`. That is a renderer-configuration concern, set at the [``](/api/components/canvas) level, not on `createXR`. + +## See also + +- [`useFrame`](/api/hooks/use-frame) — its `frame` argument carries the `XRFrame` during a session. +- [``](/api/components/canvas) — its `ref` accepts `xr.connect` and supports a cleanup return. +- [`useThree`](/api/hooks/use-three) — the `Context` that `connect` receives, including `gl` and `render`. diff --git a/site/src/routes/api/hooks/use-three.mdx b/site/src/routes/api/hooks/use-three.mdx index 949fe518..b6874ebc 100644 --- a/site/src/routes/api/hooks/use-three.mdx +++ b/site/src/routes/api/hooks/use-three.mdx @@ -50,7 +50,9 @@ The camera and raycaster are each managed as a **stack**. The `camera` and `rayc ### WebXR -solid-three does not manage XR sessions for you. While a session is presenting, the renderer's own loop (driven by the headset) owns rendering, so solid-three's window loop steps aside automatically. You wire the session directly on the renderer, installing the render callback **before** `setSession` runs: +solid-three does not manage XR sessions for you — entering and exiting a session is the consumer's job. For almost all cases, reach for [`createXR`](/api/hooks/create-xr), which handles the ordering rules (install the render loop before the session starts, request the session inside the user gesture, release the loop on exit) so you don't have to. + +If you need to drive the renderer directly instead, the rule to remember is: install the render callback **before** `setSession` runs. ```tsx const { gl, render } = useThree() @@ -59,14 +61,14 @@ const { gl, render } = useThree() gl.setAnimationLoop(render) // must be set before setSession gl.xr.enabled = true const session = await navigator.xr.requestSession("immersive-vr") -await gl.xr.setSession(session) // three's AR/VR button usually does this +await gl.xr.setSession(session) // exit XR gl.setAnimationLoop(null) gl.xr.enabled = false ``` -The same code works for both `WebGLRenderer` and `WebGPURenderer`. For XR with `WebGPURenderer` on three ≤ r184, construct it with `{ forceWebGL: true }` (the WebGPU backend's XR support is unreleased as of r184). +While a session is presenting, the renderer's own loop (driven by the headset) owns rendering, so solid-three's window loop steps aside automatically. The same code works for both `WebGLRenderer` and `WebGPURenderer`. For XR with `WebGPURenderer` on three ≤ r184, construct it with `{ forceWebGL: true }` (the WebGPU backend's XR support is unreleased as of r184). ## Examples diff --git a/site/vite.config.ts b/site/vite.config.ts index 9bc82088..403e9c35 100644 --- a/site/vite.config.ts +++ b/site/vite.config.ts @@ -97,6 +97,7 @@ export default defineConfig({ items: [ { title: "useThree", link: "/hooks/use-three" }, { title: "useFrame", link: "/hooks/use-frame" }, + { title: "createXR", link: "/hooks/create-xr" }, { title: "useLoader", link: "/hooks/use-loader" }, { title: "useProps", link: "/hooks/use-props" }, ], From e61b351c6077df829424107c790d0f1f24efc98d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 17:39:53 +0200 Subject: [PATCH 18/27] docs(xr): trim createXR docs to reader altitude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut author-facing internals (snapshot timing, double-drive, window-loop handoff, WebGL-vs-WebGPU parity) from the createXR page and README — those live in the design spec and the useThree manual-driving escape hatch. Keep the one rule a user must follow (call enter from the click handler) as an aside by the VR example. Collapse the Behavior section into the Returns table + a two-line Notes block; trim redundant parentheticals. (cherry picked from commit 2d768bea982590e136007596ad80a8656fb90fa7) --- README.md | 6 ++--- site/src/routes/api/components/canvas.mdx | 2 +- site/src/routes/api/hooks/create-xr.mdx | 27 +++++++++-------------- site/src/routes/api/hooks/use-three.mdx | 4 ++-- 4 files changed, 16 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index efc6abec..89840427 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ The `Canvas` component initializes the `three.js` rendering context and acts as - `"never"`: Disables automatic rendering - **style**: Custom CSS styles for the canvas container. - **class**: CSS class names for the canvas container. -- **ref**: Receives the scene's [`Context`](#usethree) once the renderer is created, so code outside `` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts (the React-19 cleanup-callback-ref shape) — [`createXR`](#createxr) uses this to release the renderer when the scene goes away. +- **ref**: Receives the scene's [`Context`](#usethree) once the renderer is created, so code outside `` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts — [`createXR`](#createxr) uses this. - **Event handlers**: All event handlers are supported on the Canvas component, allowing you to handle events that bubble through the entire scene (e.g., `onClick`, `onPointerMove`, `onClickMissed`, etc.)
@@ -599,9 +599,9 @@ useFrame( Enters and exits WebXR sessions (VR and AR) for the scene's renderer. The same code works on both `WebGLRenderer` and `WebGPURenderer`. -Unlike the `use*` hooks, `createXR` is called **outside** `` — typically in the same component as your "Enter XR" button, which lives in the DOM rather than the 3D scene. You connect it to the renderer with ``. `createXR` owns a reactive effect, so call it in a component body (like `createSignal`). +Unlike the `use*` hooks, `createXR` lives **outside** `` — next to the DOM "Enter XR" button that triggers a session. Call it in a component body (like `createSignal`), then connect it to the renderer with ``. -It exists because entering a session correctly has a few ordering rules that are easy to get wrong — the animation loop must be installed before the session starts, the immersive request must run synchronously inside the button's click handler, and the loop must be released on exit. `createXR` handles all of them so you don't have to. +It handles the ordering rules that make a session enter cleanly — most importantly, call `enter` directly from a click handler (see the example). **Returns** an object with: diff --git a/site/src/routes/api/components/canvas.mdx b/site/src/routes/api/components/canvas.mdx index f1815e4b..e4ebab7f 100644 --- a/site/src/routes/api/components/canvas.mdx +++ b/site/src/routes/api/components/canvas.mdx @@ -22,7 +22,7 @@ title: Canvas | `fallback` | `JSX.Element` | — | Shown while content loads asynchronously. | | `style` | `JSX.CSSProperties` | — | CSS for the canvas container. | | `class` | `string` | — | CSS class for the canvas container. | -| `ref` | `RefWithCleanup` | — | Receives the [`Context`](/api/hooks/use-three) once the renderer is created, so code outside `` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts (the React-19 cleanup-callback-ref shape); [`createXR`](/api/hooks/create-xr) uses this. | +| `ref` | `RefWithCleanup` | — | Receives the [`Context`](/api/hooks/use-three) once the renderer is created, so code outside `` can reach it. A callback ref may return a cleanup that runs when the Canvas unmounts; [`createXR`](/api/hooks/create-xr) uses this. | | event handlers | `Partial` | — | Any [event handler](/api/events/overview), firing after the event bubbles through the whole scene (e.g. `onClick`, `onClickMissed`). |
diff --git a/site/src/routes/api/hooks/create-xr.mdx b/site/src/routes/api/hooks/create-xr.mdx index 1e7397d5..ac9ed2e3 100644 --- a/site/src/routes/api/hooks/create-xr.mdx +++ b/site/src/routes/api/hooks/create-xr.mdx @@ -6,7 +6,7 @@ title: createXR `createXR` enters and exits WebXR sessions — VR and AR — for the scene's renderer. The same code works on both `WebGLRenderer` and `WebGPURenderer`. -Unlike the `use*` hooks, you call `createXR` **outside** ``. An "Enter XR" button is a DOM element, not part of the 3D scene, so the control that triggers a session lives next to that button — outside the canvas. You connect it to the renderer with ``. Because `createXR` owns a reactive effect, call it in a component body, the same way you'd call `createSignal`. +Unlike the `use*` hooks, `createXR` lives **outside** `` — next to the DOM "Enter XR" button that triggers a session. Call it in a component body (like `createSignal`), then connect it to the renderer with ``. ## Signature @@ -19,22 +19,12 @@ const xr = createXR() | Property | Type | Description | | --- | --- | --- | | `connect` | `(context: Context) => () => void` | A ref for ``. Hands the renderer to `createXR`, and returns a cleanup that runs when the Canvas unmounts. | -| `enter` | `(mode, sessionInit?) => Promise` | Requests a session and enters it. `mode` is `"immersive-vr"`, `"immersive-ar"`, or `"inline"`; `sessionInit` passes straight to `navigator.xr.requestSession`. You may instead pass an `XRSession` you already created, to skip the request and just wire it. | +| `enter` | `(mode, sessionInit?) => Promise` | Requests a session and enters it. `mode` is `"immersive-vr"`, `"immersive-ar"`, or `"inline"`; `sessionInit` passes straight to `navigator.xr.requestSession`. Call it directly from a click handler, without `await`ing first (see the example). You may instead pass an `XRSession` you already created, to skip the request and just wire it. | | `exit` | `() => Promise` | Ends the active session. | | `isPresenting` | `() => boolean` | Reactive — `true` while a session is presenting. | | `session` | `() => XRSession \| undefined` | Reactive — the active session, or `undefined`. | | `isSupported` | `(mode: XRSessionMode) => Promise` | Whether the device supports a session mode. Use it to decide whether to show the button. | -## Behavior - -`createXR` exists because entering a session has a few ordering rules that are easy to get wrong, and silently break rendering when you do. It handles all of them: - -- **It installs the render loop before starting the session.** `WebGPURenderer` captures the animation loop at the moment the session starts, so the loop has to be in place first. `createXR` installs it before calling `setSession`. -- **It requests the session synchronously.** Immersive requests need [transient user activation](https://developer.mozilla.org/en-US/docs/Web/API/XRSystem/requestSession) — they have to run inside the user-gesture handler. So call `enter` directly from a button's `onClick`, and don't `await` anything (such as `isSupported`) before it. -- **It releases the loop on exit.** When the session ends, `createXR` hands the frame loop back to solid-three's own window loop, so the flat canvas keeps rendering. - -While a session is presenting, solid-three's window loop steps aside automatically — the renderer's own loop, driven by the headset, owns rendering. `createXR` depends only on the renderer's public `xr` event target, so it has no `WebGLRenderer`-versus-`WebGPURenderer` branches. - ## Examples A VR scene with a DOM "Enter VR" button: @@ -62,6 +52,8 @@ function App() { } ``` +> Call `enter` straight from the click handler — don't `await` anything (like `isSupported`) before it, or the immersive request loses its [user activation](https://developer.mozilla.org/en-US/docs/Web/API/XRSystem/requestSession) and fails. + AR with feature descriptors, shown only when the device supports it: ```tsx @@ -96,12 +88,13 @@ function Player() { } ``` -## WebGPU note +## Notes -`createXR` is renderer-agnostic. On three.js ≤ r184, driving a WebXR session through the WebGPU backend needs a WebGL2 fallback — construct the renderer with `{ forceWebGL: true }`. That is a renderer-configuration concern, set at the [``](/api/components/canvas) level, not on `createXR`. +- **WebGPU on three ≤ r184:** driving XR through the WebGPU backend needs a WebGL2 fallback — build the renderer with `{ forceWebGL: true }` at the [``](/api/components/canvas) level. +- To drive a session manually instead of using `createXR`, see [`useThree`](/api/hooks/use-three#webxr). ## See also -- [`useFrame`](/api/hooks/use-frame) — its `frame` argument carries the `XRFrame` during a session. -- [``](/api/components/canvas) — its `ref` accepts `xr.connect` and supports a cleanup return. -- [`useThree`](/api/hooks/use-three) — the `Context` that `connect` receives, including `gl` and `render`. +- [`useFrame`](/api/hooks/use-frame) — carries the `XRFrame` per session frame. +- [``](/api/components/canvas) — its `ref` accepts `xr.connect`. +- [`useThree`](/api/hooks/use-three) — the `Context` `connect` receives. diff --git a/site/src/routes/api/hooks/use-three.mdx b/site/src/routes/api/hooks/use-three.mdx index b6874ebc..4e3f5ea8 100644 --- a/site/src/routes/api/hooks/use-three.mdx +++ b/site/src/routes/api/hooks/use-three.mdx @@ -50,7 +50,7 @@ The camera and raycaster are each managed as a **stack**. The `camera` and `rayc ### WebXR -solid-three does not manage XR sessions for you — entering and exiting a session is the consumer's job. For almost all cases, reach for [`createXR`](/api/hooks/create-xr), which handles the ordering rules (install the render loop before the session starts, request the session inside the user gesture, release the loop on exit) so you don't have to. +solid-three does not manage XR sessions for you — entering and exiting a session is the consumer's job. For almost all cases, reach for [`createXR`](/api/hooks/create-xr), which handles the session-ordering rules for you. If you need to drive the renderer directly instead, the rule to remember is: install the render callback **before** `setSession` runs. @@ -68,7 +68,7 @@ gl.setAnimationLoop(null) gl.xr.enabled = false ``` -While a session is presenting, the renderer's own loop (driven by the headset) owns rendering, so solid-three's window loop steps aside automatically. The same code works for both `WebGLRenderer` and `WebGPURenderer`. For XR with `WebGPURenderer` on three ≤ r184, construct it with `{ forceWebGL: true }` (the WebGPU backend's XR support is unreleased as of r184). +While a session is presenting, the renderer's own loop (driven by the headset) owns rendering, so solid-three's window loop steps aside automatically. The same code works for both `WebGLRenderer` and `WebGPURenderer`. For XR with `WebGPURenderer` on three ≤ r184, construct it with `{ forceWebGL: true }`. ## Examples From 9464f9dc351f0afef92f734ad286bfd71ef38f4b Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Tue, 2 Jun 2026 17:54:24 +0200 Subject: [PATCH 19/27] refactor(xr): narrow connect to XRContext (gl + render), not full Context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connect only ever reads gl and render off the Context, so type its parameter as XRContext = Pick and export it. A full Context still satisfies it, so is unchanged (locked by a compile-time assertion), but the signature now states the real dependency and a bare { gl, render } works — createXR is tied to neither nor its ref. Docs + spec reworded to match. (cherry picked from commit daa4786be8202d6fda8b2fa040e396c1ef50d4d7) --- .../specs/2026-06-02-create-xr-design.md | 9 ++++--- site/src/routes/api/hooks/create-xr.mdx | 2 +- src/create-xr.tsx | 25 +++++++++++-------- src/index.ts | 1 + tests/core/create-xr.test.tsx | 15 +++++++---- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-create-xr-design.md b/docs/superpowers/specs/2026-06-02-create-xr-design.md index 757a3b74..9c3d44c5 100644 --- a/docs/superpowers/specs/2026-06-02-create-xr-design.md +++ b/docs/superpowers/specs/2026-06-02-create-xr-design.md @@ -49,7 +49,7 @@ No provider, no new Canvas prop, no `createSignal` juggling. The button closes o ```ts const xr = createXR() -xr.connect // Ref — pass to ; returns a cleanup +xr.connect // Ref — pass to ; returns a cleanup xr.enter(mode, init?) // request a session + wire it → Promise xr.enter(session) // escape hatch: wire a session you made → Promise xr.exit() // end the active session → Promise @@ -101,16 +101,17 @@ export function createXR() { `isPresenting()` is driven by three's authoritative `sessionstart`/`sessionend` events (the decision over a hook-local boolean), so it reflects the renderer's actual state even if a session starts or ends outside `enter`/`exit`, and stays consistent with core's own `sessionend` listener. `session()` is the handle: set in `enter` once obtained, cleared on `sessionend`. -### `connect` — minimal `Ref` with a cleanup return +### `connect` — minimal `Ref` with a cleanup return ```ts -function connect(context: Context) { +// XRContext = Pick — the only fields createXR reads. +function connect(context: XRContext) { setContext(context) return () => setContext(undefined) // Canvas unmount → clear → cascades teardown via the effect } ``` -`connect` does nothing but stash the context and hand back a disconnect. It reads no members itself; the effect and `enter`/`exit` read `context.gl`/`context.render` lazily. The single requirement `connect` imposes lands on `createXR`, not itself: the effect needs a reactive owner, so `createXR()` is a component-body primitive. +`connect` does nothing but stash the context and hand back a disconnect. It reads no members itself; the effect and `enter`/`exit` read `context.gl`/`context.render` lazily. The parameter is narrowed to `XRContext` (`Pick`) so the type states the real dependency: `createXR` needs the renderer and solid-three's per-frame callback, nothing else. A full `Context` satisfies it, so `` is unchanged — but `connect` isn't tied to `` or its ref; any `{ gl, render }` works. The single requirement `connect` imposes lands on `createXR`, not itself: the effect needs a reactive owner, so `createXR()` is a component-body primitive. ### `enter` — request-first, then snapshot-safe wiring diff --git a/site/src/routes/api/hooks/create-xr.mdx b/site/src/routes/api/hooks/create-xr.mdx index ac9ed2e3..0a1d31c6 100644 --- a/site/src/routes/api/hooks/create-xr.mdx +++ b/site/src/routes/api/hooks/create-xr.mdx @@ -18,7 +18,7 @@ const xr = createXR() | Property | Type | Description | | --- | --- | --- | -| `connect` | `(context: Context) => () => void` | A ref for ``. Hands the renderer to `createXR`, and returns a cleanup that runs when the Canvas unmounts. | +| `connect` | `(context: XRContext) => () => void` | Hands `createXR` the renderer `gl` and the per-frame `render` (an `XRContext` — `Pick`), and returns a cleanup that detaches them. `` is the usual wiring — it passes the scene's `Context` for you — but `connect` isn't tied to ``; any `{ gl, render }` works. | | `enter` | `(mode, sessionInit?) => Promise` | Requests a session and enters it. `mode` is `"immersive-vr"`, `"immersive-ar"`, or `"inline"`; `sessionInit` passes straight to `navigator.xr.requestSession`. Call it directly from a click handler, without `await`ing first (see the example). You may instead pass an `XRSession` you already created, to skip the request and just wire it. | | `exit` | `() => Promise` | Ends the active session. | | `isPresenting` | `() => boolean` | Reactive — `true` while a session is presenting. | diff --git a/src/create-xr.tsx b/src/create-xr.tsx index 520018ea..348488d6 100644 --- a/src/create-xr.tsx +++ b/src/create-xr.tsx @@ -1,6 +1,15 @@ import { createRenderEffect, createSignal, onCleanup } from "solid-js" import type { Context } from "./types.ts" +/** + * What `createXR` needs from a scene to drive a session: the renderer (`gl`) and + * solid-three's per-frame callback (`render`, installed as the XR animation + * loop). A full [`Context`](./types.ts) satisfies it, so the usual wiring is + * `` — but `connect` isn't tied to `` or its + * ref; any `{ gl, render }` works. + */ +export type XRContext = Pick + /** * The structural slice of a renderer that `createXR` drives. WebGLRenderer's * `WebXRManager` and WebGPURenderer's `XRManager` expose the same runtime XR @@ -19,10 +28,6 @@ type XRRenderer = { } } -function asXRRenderer(gl: Context["gl"]): XRRenderer { - return gl as unknown as XRRenderer -} - /** * Consumer-owned WebXR entry primitive. Call it in a component body (it owns a * reactive effect), then connect it to the renderer with @@ -36,11 +41,11 @@ function asXRRenderer(gl: Context["gl"]): XRRenderer { * 3. `requestSession` is called first, with nothing awaited before it, to keep * the immersive request inside transient user activation. * - * Built only on the public `Context` and the renderer's `xr` event target — no - * core internals, no WebGL-vs-WebGPU branching. + * Built only on the public [`XRContext`](#XRContext) (`gl` + `render`) and the + * renderer's `xr` event target — no core internals, no WebGL-vs-WebGPU branching. */ export function createXR() { - const [context, setContext] = createSignal() + const [context, setContext] = createSignal() const [presenting, setPresenting] = createSignal(false) const [session, setSession] = createSignal() @@ -49,7 +54,7 @@ export function createXR() { // (via connect's disconnect) cascades through here to tear everything down. createRenderEffect(() => { const ctx = context() - const gl = ctx ? asXRRenderer(ctx.gl) : undefined + const gl = ctx ? (ctx.gl as unknown as XRRenderer) : undefined const xr = gl?.xr if (!gl || !xr || typeof xr.addEventListener !== "function") return const onStart = () => setPresenting(true) @@ -67,7 +72,7 @@ export function createXR() { }) }) - function connect(value: Context) { + function connect(value: XRContext) { setContext(value) return () => setContext(undefined) } @@ -84,7 +89,7 @@ export function createXR() { if (!ctx) { throw new Error("S3: createXR().enter() called before connected") } - const gl = asXRRenderer(ctx.gl) + const gl = ctx.gl as unknown as XRRenderer if (!gl.xr) { throw new Error("S3: the active renderer has no xr manager") } diff --git a/src/index.ts b/src/index.ts index 6bffd072..8f8892a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { Entity, Portal, Resource } from "./components.tsx" export { $S3C } from "./constants.ts" export { createEntity, createT } from "./create-t.tsx" export { createXR } from "./create-xr.tsx" +export type { XRContext } from "./create-xr.tsx" export { useFrame, useLoader, useThree } from "./hooks.ts" export { useProps } from "./props.ts" export * from "./raycasters.tsx" diff --git a/tests/core/create-xr.test.tsx b/tests/core/create-xr.test.tsx index d80fdf0c..d56acbdc 100644 --- a/tests/core/create-xr.test.tsx +++ b/tests/core/create-xr.test.tsx @@ -1,7 +1,12 @@ import { createRoot, createSignal } from "solid-js" import { afterEach, describe, expect, it, vi } from "vitest" -import { createXR } from "../../src/create-xr.tsx" -import type { Context } from "../../src/types.ts" +import type { CanvasProps } from "../../src/canvas.tsx" +import { createXR, type XRContext } from "../../src/create-xr.tsx" + +// Compile-time only: narrowing connect's param to XRContext must keep it +// assignable to `` (Context satisfies XRContext). +const _connectIsAssignableToCanvasRef: CanvasProps["ref"] = (_context: XRContext) => () => {} +void _connectIsAssignableToCanvasRef /* -------------------------------- fakes -------------------------------- */ @@ -32,7 +37,7 @@ function makeFakeGl(xr = makeFakeXR()) { type FakeGl = ReturnType function makeFakeContext(gl: FakeGl = makeFakeGl()) { - return { gl, render: vi.fn() } as unknown as Context & { + return { gl, render: vi.fn() } as unknown as XRContext & { gl: FakeGl render: ReturnType } @@ -102,7 +107,7 @@ describe("createXR — state & wiring", () => { return gl() }, render: vi.fn(), - } as unknown as Context + } as unknown as XRContext const { xr, dispose } = renderXR() xr.connect(ctx) @@ -117,7 +122,7 @@ describe("createXR — state & wiring", () => { it("does not crash for an xr manager lacking addEventListener", () => { const gl = { xr: { enabled: false }, setAnimationLoop: vi.fn() } - const ctx = { gl, render: vi.fn() } as unknown as Context + const ctx = { gl, render: vi.fn() } as unknown as XRContext const { xr, dispose } = renderXR() expect(() => xr.connect(ctx)).not.toThrow() dispose() From 1dbe2372c89e9ea67b62900568c3464cc7ee4c82 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Wed, 3 Jun 2026 01:25:51 +0200 Subject: [PATCH 20/27] docs(xr): design spec for useXR + createXR().Provider (in-scene XR state) docs(xr): implementation plan for useXR + createXR().Provider (cherry picked from commit 981163e37233a92bf42d13b5a5125986f87911be) --- docs/superpowers/plans/2026-06-03-usexr.md | 590 ++++++++++++++++++ .../specs/2026-06-03-usexr-design.md | 172 +++++ 2 files changed, 762 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-usexr.md create mode 100644 docs/superpowers/specs/2026-06-03-usexr-design.md diff --git a/docs/superpowers/plans/2026-06-03-usexr.md b/docs/superpowers/plans/2026-06-03-usexr.md new file mode 100644 index 00000000..9b196eb6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-usexr.md @@ -0,0 +1,590 @@ +# useXR + createXR().Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Distribute `createXR`'s existing `isPresenting`/`session`/`exit` state into the scene via a Solid context, so scene components can read XR state and drive in-world UI (e.g. an in-VR exit button). + +**Architecture:** `createXR` already owns the XR signals (one effect on `gl.xr` `sessionstart`/`sessionend`, `create-xr.tsx:55-73`). Phase 1 adds a module-level context, a `Provider` component returned from `createXR()`, and a `useXR()` reader — purely additive, no core/event-system changes. Solid's owner-based context crosses the `` boundary natively (children are created in the outer owner), so a `Provider` wrapping `` reaches `useXR` in the scene. + +**Tech Stack:** TypeScript, SolidJS (`createContext`/`useContext`), three.js, vitest browser mode (Playwright + Chromium, software WebGL via SwiftShader). jsdom unsupported. + +**Spec:** `docs/superpowers/specs/2026-06-03-usexr-design.md`. Only phase 1 is built here; phases 2–3 (controller input, multi-pointer event refactor) are future branches. + +**Commands:** +- Single test file: `pnpm exec vitest run tests/core/use-xr.test.tsx` +- Filter by name: `pnpm exec vitest run tests/core/use-xr.test.tsx -t "throws"` +- Full suite: `pnpm test` +- Typecheck: `pnpm lint:types` +- Lint: `pnpm lint:code` + +--- + +## File Structure + +- **Modify `src/create-xr.tsx`** — add `xrContext` (module-level), the `XRState` type, the `useXR` hook (co-located with the context to avoid a cross-module import), assemble a `state: XRState`, and return a `Provider` component from `createXR()`. +- **Modify `src/index.ts`** — export `useXR` and the `XRState` type. +- **Create `tests/core/use-xr.test.tsx`** — unit tests (throw, distribution, tracking, session/exit) with fakes, plus the cross-Canvas bridge test with a real ``. +- **Modify `README.md`** — add a `### useXR` section and a `Provider` line in the `createXR` returns. +- **Create `site/src/routes/api/hooks/use-xr.mdx`** — API page mirroring `create-xr.mdx`, registered next to it. + +--- + +### Task 1: Add `xrContext`, `XRState`, and `useXR` (throws outside provider) + +**Files:** +- Modify: `src/create-xr.tsx` (imports at line 1; add context/type/hook near the top, after imports) +- Test: `tests/core/use-xr.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `tests/core/use-xr.test.tsx`: + +```tsx +import { createRoot } from "solid-js" +import { describe, expect, it } from "vitest" +import { useXR } from "../../src/create-xr.tsx" + +describe("useXR", () => { + it("throws when used outside ", () => { + createRoot(dispose => { + expect(() => useXR()).toThrow(/useXR must be used within/) + dispose() + }) + }) +}) +``` + +- [ ] **Step 2: Run the test — expect failure** + +Run: `pnpm exec vitest run tests/core/use-xr.test.tsx -t "throws"` +Expected: FAIL — `useXR` is not exported from `src/create-xr.tsx` (import error / not a function). + +- [ ] **Step 3: Add the imports** + +In `src/create-xr.tsx`, replace the first import line: + +```ts +// before +import { createRenderEffect, createSignal, onCleanup } from "solid-js" +// after +import { + type Accessor, + createContext, + createRenderEffect, + createSignal, + type JSX, + onCleanup, + useContext, +} from "solid-js" +``` + +- [ ] **Step 4: Add the context, type, and hook** + +In `src/create-xr.tsx`, immediately after the imports and before the `XRContext` type (around line 11), add: + +```ts +/** + * The in-scene XR state distributed by `createXR().Provider` and read by + * [`useXR`](#useXR). It is the read/control slice scene code needs — not the + * full `createXR` instance, whose `connect`/`enter` are meaningless in-scene. + */ +export type XRState = { + isPresenting: Accessor + session: Accessor + exit: () => Promise +} + +const xrContext = createContext() + +/** + * Reads the XR state supplied by `createXR().Provider`. Use it in a scene + * component (descendant of ``) to react to session state or drive + * in-world UI — e.g. a mesh whose `onClick` calls `exit()`, since the DOM exit + * button is not rendered while an immersive session is presenting. + * + * @throws if used outside a ``. + */ +export function useXR(): XRState { + const state = useContext(xrContext) + if (!state) { + throw new Error("S3: useXR must be used within (from createXR())") + } + return state +} +``` + +- [ ] **Step 5: Run the test — expect pass** + +Run: `pnpm exec vitest run tests/core/use-xr.test.tsx -t "throws"` +Expected: PASS. + +- [ ] **Step 6: Typecheck** + +Run: `pnpm lint:types` +Expected: exit 0. (`XRState` references `Accessor`/`XRSession`, both now imported/global.) + +- [ ] **Step 7: Commit** + +```bash +git add src/create-xr.tsx tests/core/use-xr.test.tsx +git commit -m "feat(xr): add xrContext + useXR hook (throws outside provider)" +``` + +--- + +### Task 2: Add `createXR().Provider` and wire the distributed state + +**Files:** +- Modify: `src/create-xr.tsx` (assemble `state`, add `Provider`, extend the return — the return is currently at `create-xr.tsx:117`) +- Test: `tests/core/use-xr.test.tsx` + +- [ ] **Step 1: Add the distribution + tracking tests** + +Append to `tests/core/use-xr.test.tsx` (add `vi` to the vitest import, and `createXR` + the `XRState` type to the create-xr import): + +```tsx +// update the imports at the top of the file to: +import { createRoot } from "solid-js" +import { afterEach, describe, expect, it, vi } from "vitest" +import { createXR, useXR, type XRState } from "../../src/create-xr.tsx" + +/* -------------------------------- fakes -------------------------------- */ +// three's EventDispatcher dispatches plain `{ type }` objects and exposes +// add/removeEventListener — mirror that, not DOM EventTarget. +function makeFakeXR() { + const listeners: Record void>> = {} + return { + enabled: false, + isPresenting: false, + setSession: vi.fn(async (_session: XRSession) => {}), + addEventListener: vi.fn((type: string, l: (e: { type: string }) => void) => { + ;(listeners[type] ??= new Set()).add(l) + }), + removeEventListener: vi.fn((type: string, l: (e: { type: string }) => void) => { + listeners[type]?.delete(l) + }), + dispatch(type: string) { + listeners[type]?.forEach(l => l({ type })) + }, + } +} +function makeFakeGl(xr = makeFakeXR()) { + return { xr, setAnimationLoop: vi.fn() } +} +function makeFakeContext(gl = makeFakeGl()) { + return { gl, render: vi.fn() } as any +} +function makeFakeSession() { + return { end: vi.fn(async () => {}) } as unknown as XRSession +} +function setFakeNavigatorXR(requestSession = vi.fn(async () => makeFakeSession())) { + const xr = { requestSession, isSessionSupported: vi.fn(async () => true) } + Object.defineProperty(navigator, "xr", { value: xr, configurable: true, writable: true }) + return xr +} + +// createXR owns an effect, so it must run in an owner. Render a Probe under the +// provider; `api` is captured synchronously when the JSX is created. +function renderUseXR(ctx = makeFakeContext()) { + let api!: XRState + let xr!: ReturnType + const dispose = createRoot(d => { + xr = createXR() + xr.connect(ctx) + function Probe() { + api = useXR() + return null + } + void () + return d + }) + return { xr, ctx, get api() { return api }, dispose } +} + +afterEach(() => { + Reflect.deleteProperty(navigator as unknown as Record, "xr") +}) + +describe("createXR().Provider + useXR", () => { + it("distributes reactive isPresenting to consumers under the provider", () => { + const { ctx, api, dispose } = renderUseXR() + expect(api.isPresenting()).toBe(false) + ctx.gl.xr.dispatch("sessionstart") + expect(api.isPresenting()).toBe(true) + ctx.gl.xr.dispatch("sessionend") + expect(api.isPresenting()).toBe(false) + dispose() + }) + + it("exposes session() and exit() through useXR", async () => { + const session = makeFakeSession() + setFakeNavigatorXR(vi.fn(async () => session)) + const { xr, api, dispose } = renderUseXR() + await xr.enter("immersive-vr") + expect(api.session()).toBe(session) + await api.exit() + expect((session as unknown as { end: ReturnType }).end).toHaveBeenCalled() + dispose() + }) +}) +``` + +- [ ] **Step 2: Run the new tests — expect failure** + +Run: `pnpm exec vitest run tests/core/use-xr.test.tsx -t "Provider"` +Expected: FAIL — `xr.Provider` is `undefined` (not yet returned by `createXR`), so creating `` throws. + +- [ ] **Step 3: Assemble `state` and add `Provider`** + +In `src/create-xr.tsx`, inside `createXR()`, after the `isSupported` function and before the `return` statement (currently `create-xr.tsx:117`), add: + +```tsx + const state: XRState = { isPresenting: presenting, session, exit } + + /** + * Distributes this `createXR`'s state into the scene via context. Wrap the + * subtree that needs in-scene XR access (typically `` and its + * "Enter XR" button); descendants read it with [`useXR`](#useXR). + */ + function Provider(props: { children: JSX.Element }) { + return {props.children} + } +``` + +- [ ] **Step 4: Add `Provider` to the return** + +In `src/create-xr.tsx`, change the return (currently `create-xr.tsx:117`): + +```ts +// before + return { connect, enter, exit, isSupported, isPresenting: presenting, session } +// after + return { connect, enter, exit, isSupported, isPresenting: presenting, session, Provider } +``` + +- [ ] **Step 5: Run the new tests — expect pass** + +Run: `pnpm exec vitest run tests/core/use-xr.test.tsx -t "Provider"` +Expected: PASS (both distribution and session/exit tests). + +- [ ] **Step 6: Run the whole file + typecheck + lint** + +Run: `pnpm exec vitest run tests/core/use-xr.test.tsx` +Expected: PASS (3 tests). +Run: `pnpm lint:types` +Expected: exit 0. +Run: `pnpm lint:code` +Expected: exit 0. + +- [ ] **Step 7: Commit** + +```bash +git add src/create-xr.tsx tests/core/use-xr.test.tsx +git commit -m "feat(xr): createXR().Provider distributes XR state to useXR" +``` + +--- + +### Task 3: Export `useXR` and `XRState` from the package entry + +**Files:** +- Modify: `src/index.ts:6-7` +- Test: `tests/core/use-xr.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/core/use-xr.test.tsx`: + +```tsx +describe("package entry", () => { + it("re-exports useXR from solid-three", async () => { + const mod = await import("../../src/index.ts") + expect(typeof mod.useXR).toBe("function") + }) +}) +``` + +- [ ] **Step 2: Run the test — expect failure** + +Run: `pnpm exec vitest run tests/core/use-xr.test.tsx -t "re-exports"` +Expected: FAIL — `mod.useXR` is `undefined`. + +- [ ] **Step 3: Update the exports** + +In `src/index.ts`, change lines 6–7: + +```ts +// before +export { createXR } from "./create-xr.tsx" +export type { XRContext } from "./create-xr.tsx" +// after +export { createXR, useXR } from "./create-xr.tsx" +export type { XRContext, XRState } from "./create-xr.tsx" +``` + +- [ ] **Step 4: Run the test — expect pass** + +Run: `pnpm exec vitest run tests/core/use-xr.test.tsx -t "re-exports"` +Expected: PASS. + +- [ ] **Step 5: Typecheck** + +Run: `pnpm lint:types` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/index.ts tests/core/use-xr.test.tsx +git commit -m "feat(xr): export useXR and XRState from package entry" +``` + +--- + +### Task 4: Cross-Canvas bridge test (real ``) + +This is the load-bearing test: it proves a `Provider` *outside* `` reaches a component *inside* the scene graph. Uses a real `WebGLRenderer` (browser mode) whose `gl.xr` is a three `WebXRManager` (extends `EventDispatcher`, so `dispatchEvent({ type })` works). + +**Files:** +- Test: `tests/core/use-xr.test.tsx` + +- [ ] **Step 1: Write the bridge test** + +Add to the top imports of `tests/core/use-xr.test.tsx`: + +```tsx +import { render } from "solid-js/web" +import { Canvas } from "../../src/canvas.tsx" +import { useThree } from "../../src/hooks.ts" +``` + +Append the test: + +```tsx +describe("createXR().Provider — across the Canvas boundary", () => { + it("bridges provider state into a component inside ", async () => { + const host = document.createElement("div") + document.body.appendChild(host) + + let isPresenting!: () => boolean + let gl!: { xr: { dispatchEvent: (e: { type: string }) => void }; dispose?: () => void; forceContextLoss?: () => void } + + function Probe() { + isPresenting = useXR().isPresenting + gl = useThree(c => c.gl)() as typeof gl + return null + } + function App() { + const xr = createXR() + return ( + + + + + + ) + } + + const dispose = render(() => , host) + // Canvas creates the renderer in onMount; wait one frame so xr.connect ran + // and the sessionstart/sessionend listeners are attached to gl.xr. + await new Promise(resolve => requestAnimationFrame(() => resolve(null))) + + expect(isPresenting()).toBe(false) + gl.xr.dispatchEvent({ type: "sessionstart" }) + expect(isPresenting()).toBe(true) + gl.xr.dispatchEvent({ type: "sessionend" }) + expect(isPresenting()).toBe(false) + + dispose() + // Free the GPU context — browsers cap concurrent WebGL contexts (~16). + gl.dispose?.() + gl.forceContextLoss?.() + host.remove() + }) +}) +``` + +- [ ] **Step 2: Run the bridge test** + +Run: `pnpm exec vitest run tests/core/use-xr.test.tsx -t "bridges"` +Expected: PASS — the in-scene `Probe` observes `isPresenting()` flip, proving context crosses the Canvas boundary. + +- [ ] **Step 3: Run the whole file** + +Run: `pnpm exec vitest run tests/core/use-xr.test.tsx` +Expected: PASS (5 tests). + +- [ ] **Step 4: Commit** + +```bash +git add tests/core/use-xr.test.tsx +git commit -m "test(xr): bridge test — provider state crosses into the scene" +``` + +--- + +### Task 5: Documentation (README + API site) + +**Files:** +- Modify: `README.md` (the `### createXR` section — returns list + a new `### useXR` section after it) +- Create: `site/src/routes/api/hooks/use-xr.mdx` + +- [ ] **Step 1: Add `Provider` to the createXR returns in README** + +In `README.md`, in the `### createXR` "**Returns** an object with:" list, add a bullet after the `isSupported` bullet: + +```md +- **Provider** (`(props: { children: JSX.Element }) => JSX.Element`): Distributes this `createXR`'s state to the scene. Wrap the subtree that needs in-scene XR access (typically `` and its button); read it inside with [`useXR`](#usexr). +``` + +- [ ] **Step 2: Add the `### useXR` section in README** + +In `README.md`, immediately after the end of the `### createXR` section (before `### useLoader`), insert: + +````md +### useXR + +Reads the XR state distributed by [`createXR().Provider`](#createxr) from **inside the scene**. Use it when a component needs to react to the session or drive in-world UI — most importantly an in-VR exit control, since the DOM "Exit" button is not rendered while an immersive session is presenting. + +Wrap the subtree with ``, then call `useXR()` in any descendant (including components inside ``): + +```tsx +import { Canvas, createXR, useXR } from "solid-three" +import { Show } from "solid-js" + +function ExitButton() { + const { isPresenting, exit } = useXR() + return ( + + exit()}> + + + + + ) +} + +function App() { + const xr = createXR() + return ( + + + + + + + ) +} +``` + +**Returns** the read/control slice of the XR state: + +- **isPresenting** (`() => boolean`): Reactive — `true` while a session is presenting. +- **session** (`() => XRSession | undefined`): Reactive — the active session, or `undefined`. +- **exit** (`() => Promise`): Ends the active session. + +> `useXR` throws if called outside a ``. For controller and hand poses, read the `XRFrame` from [`useFrame`](#useframe)'s third argument — that does not require `useXR`. +```` + +- [ ] **Step 3: Add the `useXR` entry to the README table of contents** + +In `README.md`, in the Hooks list of the table of contents, add `useXR` after the `createXR` entry: + +```md + - [useXR](#usexr) +``` + +- [ ] **Step 4: Find how create-xr.mdx is registered** + +Run: `grep -rn "create-xr" site/src` +Expected: shows `site/src/routes/api/hooks/create-xr.mdx` and any nav/index entry referencing it. Note the nav file and the pattern used (you will mirror it for use-xr in Step 6). + +- [ ] **Step 5: Create the API page** + +Create `site/src/routes/api/hooks/use-xr.mdx`: + +```mdx +# useXR + +Reads the XR state distributed by [`createXR().Provider`](/api/hooks/create-xr) from inside the scene. Use it to react to the session or build in-world UI — most importantly an in-VR exit control, since the DOM "Exit" button is not shown while an immersive session is presenting. + +Wrap the subtree with ``, then call `useXR()` in any descendant, including components inside ``. + +```tsx +import { Canvas, createXR, useXR } from "solid-three" +import { Show } from "solid-js" + +function ExitButton() { + const { isPresenting, exit } = useXR() + return ( + + exit()}> + + + + + ) +} + +function App() { + const xr = createXR() + return ( + + + + + + + ) +} +``` + +## Returns + +- **isPresenting** (`() => boolean`): Reactive — `true` while a session is presenting. +- **session** (`() => XRSession | undefined`): Reactive — the active session, or `undefined`. +- **exit** (`() => Promise`): Ends the active session. + +`useXR` throws if called outside a ``. For controller and hand poses, read the `XRFrame` from [`useFrame`](/api/hooks/use-frame)'s third argument — that does not require `useXR`. + +## Typescript Interface + +```tsx +function useXR(): { + isPresenting: () => boolean + session: () => XRSession | undefined + exit: () => Promise +} +``` +``` + +- [ ] **Step 6: Register the page in the nav** + +Using the nav file found in Step 4, add a `useXR` entry next to the existing `createXR` entry, mirroring its exact shape (label + path `/api/hooks/use-xr`). + +- [ ] **Step 7: Commit** + +```bash +git add README.md site/src/routes/api/hooks/use-xr.mdx site/src +git commit -m "docs(xr): document useXR + createXR().Provider" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Provider distributes existing state → Task 2. ✓ +- `useXR()` returns narrowed `{ isPresenting, session, exit }` → Task 1 (`XRState`), Task 2 (wiring). ✓ +- Throws outside provider → Task 1. ✓ +- Co-located in `create-xr.tsx`, re-exported from index → Task 1 + Task 3. ✓ +- No core/event-system changes → only `create-xr.tsx`/`index.ts` touched. ✓ +- Bridge test (context crosses Canvas) → Task 4. ✓ +- `isPresenting`/`session` track `sessionstart`/`sessionend`; `exit` ends session → Task 2 tests. ✓ +- Docs (README + API site) → Task 5. ✓ +- Phases 2–3 explicitly out of scope → no tasks, as intended. ✓ + +**2. Placeholder scan:** No TBD/TODO. The only non-literal step is Task 5 Steps 4/6 (nav registration), which is concrete: a grep to locate the existing `create-xr` nav entry and mirror it — unavoidable since the nav mechanism is the site's, not ours to assume. + +**3. Type consistency:** `XRState` is `{ isPresenting: Accessor; session: Accessor; exit: () => Promise }` in Task 1 and used identically in Tasks 2–4. `Provider` signature `(props: { children: JSX.Element }) => JSX.Element` matches between create-xr.tsx (Task 2) and the README/mdx (Task 5). `useXR` return matches the `XRState` shape everywhere. `createXR()` return gains exactly `Provider` (Task 2). diff --git a/docs/superpowers/specs/2026-06-03-usexr-design.md b/docs/superpowers/specs/2026-06-03-usexr-design.md new file mode 100644 index 00000000..48930c51 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-usexr-design.md @@ -0,0 +1,172 @@ +# `useXR` + `createXR().Provider`: in-scene XR state — design + +**Date:** 2026-06-03 +**Area:** edit `src/create-xr.tsx` (add `Provider`), new `useXR` export from `src/index.ts`, docs +**Status:** design approved, pending spec review +**Depends on:** [`createXR`](./2026-06-02-create-xr-design.md) — this layers an in-scene reader on top of the entry primitive, and the [frameloop decoupling](./2026-05-29-xr-frameloop-decoupling-design.md) beneath it. + +## Problem + +`createXR` lives *outside* `` — next to the DOM "Enter XR" button. That is correct for entering a session, but it leaves a gap once you are *in* one: an immersive session takes over the display, so the 2D DOM (including the "Exit VR" button) is not rendered in the headset. Anything the user must see or touch while presenting has to be **in the scene**, as three.js objects. + +Today the only in-scene XR access is the raw `XRFrame` forwarded through `useFrame((ctx, delta, frame) => …)`. That is enough for reading poses, but not for the common reactive needs: + +- Show/hide scene content based on whether a session is presenting (``). +- An in-world control — e.g. a mesh whose `onClick` calls `exit()` — since the DOM exit button is invisible in-session. +- Read the active `XRSession` from a scene component without threading it down by hand. + +`createXR` already owns exactly this state (`isPresenting`, `session`) and the `exit` control. The gap is purely *distribution*: getting that state from the `createXR` instance (outer scope) to components inside the scene, reactively. + +**Goal:** distribute `createXR`'s existing state into the scene through a Solid context, with a `useXR()` reader — no new `gl.xr` subscriptions, no core changes, `createXR` remaining the single source of truth. And record the larger XR-interaction work (controller input, controller rays driving the existing pointer-event system) as a phased roadmap so this PR stays small while the direction is captured. + +## Key insight: distribute existing state, don't re-derive it + +`createXR` already runs one reactive effect that listens to the renderer's `sessionstart`/`sessionend` and maintains `presenting`/`session` signals (`create-xr.tsx:55-73`). There are two ways to make that readable in-scene: + +1. **Distribute** — `createXR` exposes a `Provider` that supplies *its own* signals into a context; `useXR()` reads them. +2. **Re-derive** — `useXR()` independently subscribes to `gl.xr` events (via `useThree(c => c.gl)`) and maintains its own copy. + +Distribution wins. Re-derivation duplicates subscriptions (N hooks = N listener pairs), and worse, creates a *second* source of truth that can disagree with `createXR`'s during the event/microtask window. Distribution keeps one owner of the state and makes `useXR` a pure consumer. + +This is only feasible because **solid-three has no react-three-fiber-style renderer boundary.** In r3f, the Canvas reconciler detaches the React tree, so context from outside does not reach scene components without a manual bridge. Here, ``'s children are `canvasProps.children` — JSX created in the *outer* owner — and core layers its own contexts on top via `children(() => <…Provider>{canvasProps.children})` (`create-three.tsx:582-588`). There is no detached root for the scene graph. So a `Provider` wrapping `` naturally reaches `useXR` inside the scene, through Solid's ordinary owner-based context. + +``` + // supplies XRState into context + + + + + + ) +} +``` + +**Returns** the read/control slice of the XR state: + +- **isPresenting** (`() => boolean`): Reactive — `true` while a session is presenting. +- **session** (`() => XRSession | undefined`): Reactive — the active session, or `undefined`. +- **exit** (`() => Promise`): Ends the active session. + +> `useXR` throws if called outside a ``. For controller and hand poses, read the `XRFrame` from [`useFrame`](#useframe)'s third argument — that does not require `useXR`. + + ### useLoader Manages asynchronous resource loading, such as textures or models, and integrates with `solid-js`' reactivity system. This hook can be used with Solid's `` to handle loading states. diff --git a/site/src/routes/api/hooks/use-xr.mdx b/site/src/routes/api/hooks/use-xr.mdx new file mode 100644 index 00000000..344bdc52 --- /dev/null +++ b/site/src/routes/api/hooks/use-xr.mdx @@ -0,0 +1,56 @@ +--- +title: useXR +--- + +# useXR + +Reads the XR state distributed by [`createXR().Provider`](/api/hooks/create-xr) from inside the scene. Use it to react to the session or build in-world UI — most importantly an in-VR exit control, since the DOM "Exit" button is not shown while an immersive session is presenting. + +Wrap the subtree with ``, then call `useXR()` in any descendant, including components inside ``. + +```tsx +import { Canvas, createXR, useXR } from "solid-three" +import { Show } from "solid-js" + +function ExitButton() { + const { isPresenting, exit } = useXR() + return ( + + exit()}> + + + + + ) +} + +function App() { + const xr = createXR() + return ( + + + + + + + ) +} +``` + +## Returns + +- **isPresenting** (`() => boolean`): Reactive — `true` while a session is presenting. +- **session** (`() => XRSession | undefined`): Reactive — the active session, or `undefined`. +- **exit** (`() => Promise`): Ends the active session. + +`useXR` throws if called outside a ``. For controller and hand poses, read the `XRFrame` from [`useFrame`](/api/hooks/use-frame)'s third argument — that does not require `useXR`. + +## Typescript Interface + +```tsx +function useXR(): { + isPresenting: () => boolean + session: () => XRSession | undefined + exit: () => Promise +} +``` diff --git a/site/vite.config.ts b/site/vite.config.ts index 403e9c35..031a5e90 100644 --- a/site/vite.config.ts +++ b/site/vite.config.ts @@ -98,6 +98,7 @@ export default defineConfig({ { title: "useThree", link: "/hooks/use-three" }, { title: "useFrame", link: "/hooks/use-frame" }, { title: "createXR", link: "/hooks/create-xr" }, + { title: "useXR", link: "/hooks/use-xr" }, { title: "useLoader", link: "/hooks/use-loader" }, { title: "useProps", link: "/hooks/use-props" }, ], From b146647f6c0fd371a699e36e91d1de32ae38d8c1 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Wed, 3 Jun 2026 08:53:07 +0200 Subject: [PATCH 27/27] chore: add docs/superpowers to gitignore (cherry picked from commit 4cd57c42b44da6a17ad54b855be4665329332440) --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 02f86b9a..1bc1bfad 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ tests/**/__screenshots__/ site/.output site/.nitro site/.solid-start -site/.vinxi \ No newline at end of file +site/.vinxi +docs/superpowers \ No newline at end of file