Skip to content

feat(xr)!: consumer-owned WebXR — decouple the frame loop from core; add createXR/useXR#62

Merged
bigmistqke merged 27 commits into
solidjs-community:next-cleanupfrom
bigmistqke:next-xr
Jun 3, 2026
Merged

feat(xr)!: consumer-owned WebXR — decouple the frame loop from core; add createXR/useXR#62
bigmistqke merged 27 commits into
solidjs-community:next-cleanupfrom
bigmistqke:next-xr

Conversation

@bigmistqke
Copy link
Copy Markdown
Contributor

@bigmistqke bigmistqke commented Jun 3, 2026

Reworks solid-three's WebXR integration so the consumer owns the session and core stays out of the frame loop while a session presents. Continues #58 on top of a cleaned-up next branch.

Summary

Previously core drove the WebXR loop itself: on the renderer's sessionstart it toggled setAnimationLoop(...) and set xr.enabled. That breaks WebGPURenderer — three's WebGPU XRManager snapshots the renderer's animation loop at setSession time, before sessionstart fires, so toggling on sessionstart is too late and clobbers the manager's own frame driver. On a Quest this surfaced as gl.getContextAttributes is not a function on the WebGPU backend, and a hung headset with endless "Not all layers submitted" warnings under forceWebGL. The same internal driver also caused a latent frameloop="always" + XR double-render.

Core now depends only on the stable, cross-renderer contract — the consumer installs setAnimationLoop(render) before setSession, and core reads the read-only renderer.xr.isPresenting to yield its window loop while presenting, resuming via a single sessionend listener. No WebGL-vs-WebGPU branching. The session wiring then lives behind one small primitive so consumers can't trip the sharp edges.

What changed

createXR() — consumer-owned XR entry

A Solid primitive called in a component body (it owns one reactive effect), built only on context.gl, context.render, and the renderer's standard xr event target — no core internals, no renderer-family branching. Members: connect (a cleanup-returning Ref<XRContext> for <Canvas ref={xr.connect}>), enter(mode, init?) / enter(session), exit(), isSupported(mode), isPresenting(), session(), and Provider. It absorbs the three edges of the contract so the consumer never has to: snapshot order (setAnimationLoop(render) before setSession), post-exit double-drive (setAnimationLoop(null) on sessionend), and transient activation (requestSession first, nothing awaited before it).

useXR() + createXR().Provider — in-scene state

Wrap the subtree (Canvas + its button) in <xr.Provider>; scene components read { isPresenting, session, exit } with useXR() (which throws outside a provider). This serves in-world UI — e.g. a mesh whose onClick calls exit(), since the DOM exit button is not rendered while an immersive session presents. useXR is deliberately not the entry API: use* hooks read a provider from inside Canvas, whereas createXR() is created outside it.

Cleanup-returning refs

useRef now runs a callback ref's returned cleanup via onCleanup (the React-19 cleanup-ref shape), and <Canvas>'s ref type is widened to allow it (RefWithCleanup<T>). This is what makes xr.connect a one-liner that returns its own disconnect.

API surface

New exports: createXR, useXR, and types XRContext (Pick<Context, "gl" | "render">) and XRState. connect is typed XRContext, not the full Context, so its real dependency is explicit and any { gl, render } works — it is tied to neither <Canvas> nor its ref.

Docs

New API pages create-xr.mdx and use-xr.mdx; use-three.mdx documents the manual escape hatch and points at createXR as the recommended path; canvas.mdx documents the cleanup-returning ref. README gains createXR/useXR sections, a feature bullet, and TOC entries. Design specs and TDD plans live under docs/superpowers/.

Breaking

  • Removed Context.xr ({ connect, disconnect }) — XR is now consumer-wired (via createXR, or gl.xr directly).
  • Context.render signature changed (deltatimestamp, added frame) so the XRFrame flows through to useFrame.

Manual escape hatch

createXR is the recommended path, but the raw contract is small enough to wire by hand for both renderers: gl.setAnimationLoop(render) before gl.xr.setSession(session) (with gl.xr.enabled = true), and gl.setAnimationLoop(null); gl.xr.enabled = false to exit. For XR with WebGPURenderer on three ≤ r184, construct it with { forceWebGL: true } (WebGPU-backend XR is unreleased upstream).

Test plan

  • pnpm test — 175 passed, 2 todo (12 files): core yields the window loop while presenting and resumes on sessionend; core leaves gl.xr.enabled alone; XRFrame forwarded to useFrame; createXR state & wiring (snapshot order, post-exit loop-null, renderer-swap re-attach, no-crash on an xr lacking addEventListener, connect/disconnect); enter (request-first, provided-session overload, error paths); exit/isSupported; useXR throws outside provider; Provider bridges state across the Canvas boundary; cleanup-returning useRef.
  • pnpm lint:types — clean
  • pnpm lint:code — clean
  • Pending: on-device WebGPURenderer({ forceWebGL: true }) + WebXR on Quest 3 (the original report).

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 3, 2026

commit: b146647

@bigmistqke bigmistqke changed the title Rewrite WebXR integration feat(xr): createXR + useXR — consumer-owned WebXR entry Jun 3, 2026
@bigmistqke bigmistqke changed the title feat(xr): createXR + useXR — consumer-owned WebXR entry Rework WebXR integration: decoupled core + createXR/useXR Jun 3, 2026
bigmistqke added 27 commits June 3, 2026 09:36
Hand XR loop ownership to the consumer so core depends only on the
stable cross-renderer contract (setAnimationLoop-before-setSession +
xr.isPresenting), insulating it from the diverging, still-moving
WebGLRenderer vs WebGPURenderer XR semantics.

(cherry picked from commit 6035ad8)
Spec: one sessionend resume listener + self-stopping isPresenting guard
(was two listeners); add moving-target motivation and per-renderer
consumer contract. Plan: TDD task breakdown.

(cherry picked from commit e06a2f4)
Core no longer drives the WebXR frame loop. It keeps its own window loop
out of the way via an isPresenting guard + one sessionend resume listener,
and depends only on the stable cross-renderer contract
(setAnimationLoop-before-setSession + xr.isPresenting). The consumer wires
setAnimationLoop/enabled/setSession. Fixes WebGPURenderer XR (the old
sessionstart toggle ran too late for the WebGPU manager's setSession
snapshot) and the latent frameloop=always + XR double-render.

(cherry picked from commit e8b434c)
Code-review follow-ups:
- canvas.tsx resize no longer issues a window-driven render while an XR
  session is presenting (the third window driver that bypassed the guard).
- extract a single isPresenting() predicate used by loop + requestRender.
- sessionend resume repaints only in frameloop="demand", not "never".
- useThree docs: correct render signature, drop removed xr member, add a
  consumer-driven WebXR section.
- add coverage for a renderer whose xr manager lacks addEventListener.

(cherry picked from commit 8786785)
Design for a createXR() primitive that encapsulates the three sharp edges
of the decoupled XR contract (setAnimationLoop-before-setSession snapshot
order, the post-exit double-drive, and transient-activation request-first)
plus the structural Canvas-context-to-DOM-button gap. Also wires and widens
Canvas's currently-dead ref<Context> to support a cleanup return.

(cherry picked from commit 99e52ec)
(cherry picked from commit fcaef19)
- New API page site/src/routes/api/hooks/create-xr.mdx + sidebar entry
- README: createXR section, feature bullet, TOC; fix stale Context.render
  signature and remove the removed Context.xr field
- Canvas ref documented as a cleanup-returning Context ref (README + canvas.mdx)
- use-three WebXR section now points at createXR as the recommended path

(cherry picked from commit 412c73d)
Cut author-facing internals (snapshot timing, double-drive, window-loop
handoff, WebGL-vs-WebGPU parity) from the createXR page and README — those
live in the design spec and the useThree manual-driving escape hatch. Keep
the one rule a user must follow (call enter from the click handler) as an
aside by the VR example. Collapse the Behavior section into the Returns
table + a two-line Notes block; trim redundant parentheticals.

(cherry picked from commit 2d768be)
…text

connect only ever reads gl and render off the Context, so type its parameter
as XRContext = Pick<Context, 'gl' | 'render'> and export it. A full Context
still satisfies it, so <Canvas ref={xr.connect}> is unchanged (locked by a
compile-time assertion), but the signature now states the real dependency and
a bare { gl, render } works — createXR is tied to neither <Canvas> nor its ref.
Docs + spec reworded to match.

(cherry picked from commit daa4786)
…ate)

docs(xr): implementation plan for useXR + createXR().Provider

(cherry picked from commit 981163e)
@bigmistqke bigmistqke changed the title Rework WebXR integration: decoupled core + createXR/useXR feat(xr)!: consumer-owned WebXR — decouple the frame loop from core; add createXR/useXR Jun 3, 2026
@bigmistqke bigmistqke merged commit 824aea9 into solidjs-community:next-cleanup Jun 3, 2026
2 checks passed
@bigmistqke bigmistqke deleted the next-xr branch June 3, 2026 09:58
bigmistqke added a commit to bigmistqke/solid-three that referenced this pull request Jun 3, 2026
docs/superpowers is gitignored as internal process artifacts (specs +
plans), but six files committed before that rule landed stayed tracked:
the three design specs (via solidjs-community#62) and three plans (the commit that
un-tracked plans lived on a deleted branch and never reached next-cleanup).
Remove all six from the index (files stay on disk) so tracking matches the
ignore rule.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant