diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md new file mode 100644 index 00000000..8d70b95c --- /dev/null +++ b/.claude/commands/pr.md @@ -0,0 +1,73 @@ +--- +description: Prepare a Conventional-Commit PR title, a changeset-style body, and a squash-vs-rebase recommendation for the current branch. Text only — no pushing, no gh calls. +--- + +# /pr — prepare a pull request + +Produce the **text** for a PR on the current branch, following the project's git +workflow (see `CONTRIBUTING.md` → Git Workflow). Do **not** push, force-push, or +call `gh` / the GitHub API. Output is for the user to read, edit, and use. + +## Steps + +1. **Resolve branches.** + - Current branch: `git rev-parse --abbrev-ref HEAD`. + - Base branch: use `$ARGUMENTS` if given, else the repo default + (`git remote show ` / the branch the PR will target, typically + `next-cleanup` or `main`). + - If the current branch is `main` or a shared `solidjs-community/*` branch, + **stop** and tell the user to move the work to a feature branch — this + workflow never commits changes there directly. + +2. **Survey the change** (read-only): + - `git log --oneline --no-decorate ..HEAD` + - `git diff --stat ...HEAD` + - Read the actual diff for the non-trivial files so the summary is accurate, + not guessed. Note any breaking changes (removed/renamed exports, changed + signatures). + +3. **Draft the title** — a single [Conventional Commit](https://www.conventionalcommits.org) + line: `type(scope): summary`. + - `type` ∈ `feat | fix | docs | refactor | test | perf | chore`. + - This becomes the squash commit subject on the base branch, so write it as a + changelog entry: imperative, specific, no trailing period. + - If the branch spans more than one logical change, say so and suggest + splitting into separate PRs rather than forcing an "and" title. + +4. **Draft the body** — the changeset. Structure: + - **Summary** — what changed and why, in prose a reader understands without + the diff. + - **What changed** — bullets of the concrete edits (grouped by area). + - **Breaking** — only if applicable; list each break explicitly. + - **Test plan** — the checks actually run (lint/types/tests) with results, and + anything still pending. Run them if not already run; never fabricate results. + +5. **Recommend a merge strategy** (per CONTRIBUTING): + - **Squash** (default) when the branch's commits are scratch/exploratory work. + - **Rebase** only when the branch's own commits are already clean and each is + a meaningful, self-contained unit worth keeping on the base branch. + - State which and one line of why. + +## Style rules + +- No hard-wrapping the body at a fixed column — one line per paragraph/bullet, + let it reflow (GitHub renders it). +- No `Co-Authored-By` trailer. +- Match the repo's voice: direct, concrete, no filler. + +## Output format + +Return exactly this, ready to copy: + +``` +Title: + + +Body: + + +Merge: +``` + +Then stop. Do not push or open the PR unless the user explicitly asks in a +follow-up. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d0d746d..165b19cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,72 @@ if (context.gl.xr) { Use full names: `raycaster`, not `rc`. When a name shadows an outer scope, prefix with `_` to disambiguate (`_raycaster`). +## Git Workflow + +The history on `main` is meant to be read by humans. We keep it that way by +letting branches be messy and merges be clean. + +### Branch for every change + +Never commit directly to `main` or to any shared `solidjs-community` branch. +Create a feature branch (`feat/xr-decoupling`, `fix/resize-render`) and open a +PR from it. + +### Your branch is yours; shared branches are not + +Commit as often as you like on your own branch — exploration, WIP, and +AI-assisted commits are all fine. Force-push your own PR branch freely to clean +it up (rebase, amend, reorder). **Never** force-push `main` or a shared branch. + +The noise stays on the branch; it never has to reach `main` (see merge +strategy). So commit in whatever rhythm keeps you productive. + +### One PR, one logical change + +A PR should be one feature or one fix. If you're tempted to write "and" in the +title, it's probably two PRs. Smaller PRs squash into cleaner history and are +easier to review and revert. + +### Merge strategy + +- **Squash — the default.** Use it whenever the branch's commits are scratch + work (the AI-per-prompt case). The whole branch collapses to one commit on + `main`. The granular history isn't lost — GitHub keeps it on the PR page even + after the branch is deleted. +- **Rebase — the exception.** Use it only when the branch's *own* commits are + already clean and each is a meaningful, self-contained unit you want to keep + on `main` (e.g. a multi-phase feature split into `core →` then `consumer` + commits). This puts them on `main` linearly. +- **Never merge-commit.** Merge bubbles are the noise we're avoiding. + +### The PR title and body are the changelog + +Because we squash, **the PR title becomes the commit subject on `main` and the +PR description becomes the commit body.** They are the permanent, human-readable +record of the change — write them as such, not as a throwaway note. + +- **Title — a [Conventional Commit](https://www.conventionalcommits.org):** + `type(scope): summary`, where `type` is one of `feat`, `fix`, `docs`, + `refactor`, `test`, `perf`, `chore`. This is what shows up in `git log main`, + so it doubles as the changeset entry. +- **Body — what changed and why.** Enough that a reader six months out + understands the change without the diff. Note breaking changes explicitly. + +``` +# ❌ Bad PR title (becomes a useless commit on main) +updates + +# ✅ Good PR title +feat(xr): decouple the WebXR loop from core; add createXR +``` + +### Fixed points + +We don't cut prerelease tags. Instead, every push publishes a preview package +via [pkg.pr.new](https://pkg.pr.new), each pinned to a commit SHA — so any +build you're running maps back to an exact commit. A clean, squashed `main` plus +per-commit previews give reliable points to compare against. + ## Tooling ### Package Management diff --git a/docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md b/docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md deleted file mode 100644 index a003bd09..00000000 --- a/docs/superpowers/plans/2026-05-29-xr-frameloop-decoupling.md +++ /dev/null @@ -1,400 +0,0 @@ -# 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 — DROPPED (naming decision: keep `render`) - -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). - -- [ ] **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/plans/2026-06-02-create-xr.md b/docs/superpowers/plans/2026-06-02-create-xr.md deleted file mode 100644 index 8eed6005..00000000 --- a/docs/superpowers/plans/2026-06-02-create-xr.md +++ /dev/null @@ -1,769 +0,0 @@ -# `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. ✓ diff --git a/docs/superpowers/plans/2026-06-03-usexr.md b/docs/superpowers/plans/2026-06-03-usexr.md deleted file mode 100644 index 9b196eb6..00000000 --- a/docs/superpowers/plans/2026-06-03-usexr.md +++ /dev/null @@ -1,590 +0,0 @@ -# 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-05-29-xr-frameloop-decoupling-design.md b/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md deleted file mode 100644 index 475127c5..00000000 --- a/docs/superpowers/specs/2026-05-29-xr-frameloop-decoupling-design.md +++ /dev/null @@ -1,292 +0,0 @@ -# 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. 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 }).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)) -}) -``` - -**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`, 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 -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`. - -## 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. diff --git a/docs/superpowers/specs/2026-06-02-create-xr-design.md b/docs/superpowers/specs/2026-06-02-create-xr-design.md deleted file mode 100644 index 9c3d44c5..00000000 --- a/docs/superpowers/specs/2026-06-02-create-xr-design.md +++ /dev/null @@ -1,245 +0,0 @@ -# `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 -// 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 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 - -```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. diff --git a/docs/superpowers/specs/2026-06-03-usexr-design.md b/docs/superpowers/specs/2026-06-03-usexr-design.md deleted file mode 100644 index 48930c51..00000000 --- a/docs/superpowers/specs/2026-06-03-usexr-design.md +++ /dev/null @@ -1,172 +0,0 @@ -# `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 -