Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,34 @@
}
},
"import": {
"types": "./dist/testing/index.d.ts",
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
}
},
"./xr": {
"solid": {
"development": "./dist/xr.dev.solid.jsx",
"import": "./dist/xr.dev.js"
},
"development": {
"import": {
"types": "./dist/xr.d.ts",
"default": "./dist/xr.dev.js"
}
},
"import": {
"types": "./dist/xr.d.ts",
"default": "./dist/xr.js"
}
}
},
"typesVersions": {
"*": {
"testing": [
"./dist/testing/index.d.ts"
"./dist/testing.d.ts"
],
"xr": [
"./dist/xr.d.ts"
]
}
},
Expand Down
3 changes: 2 additions & 1 deletion site/src/routes/api/hooks/create-xr.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ const xr = createXR()
A VR scene with a DOM "Enter VR" button:

```tsx
import { Canvas, createXR } from "solid-three"
import { Canvas } from "solid-three"
import { createXR } from "solid-three/xr"
import { Show } from "solid-js"

function App() {
Expand Down
3 changes: 2 additions & 1 deletion site/src/routes/api/hooks/use-xr.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ Reads the XR state distributed by [`createXR().Provider`](/api/hooks/create-xr)
Wrap the subtree with `<xr.Provider>`, then call `useXR()` in any descendant, including components inside `<Canvas>`.

```tsx
import { Canvas, createXR, useXR } from "solid-three"
import { Canvas } from "solid-three"
import { createXR, useXR } from "solid-three/xr"
import { Show } from "solid-js"

function ExitButton() {
Expand Down
33 changes: 5 additions & 28 deletions src/create-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,33 +45,10 @@ export function createEvents(context: Context) {
// Remove the canvas listeners when the Canvas owner disposes.
onCleanup(manager.connect())

// The single registry the pointer system raycasts; refcounted so an object
// listening for several event types is listed exactly once.
const refCounts = new Map<Object3D, number>()
function addToRegistry(object: Object3D) {
const count = refCounts.get(object) ?? 0
if (count === 0) context.eventRegistry.push(object)
refCounts.set(object, count + 1)
return () => {
const current = refCounts.get(object)
if (current === undefined) return
if (current <= 1) {
refCounts.delete(object)
const index = context.eventRegistry.indexOf(object)
if (index !== -1) context.eventRegistry.splice(index, 1)
// Drop any active capture on a gone object so a drag stops dispatching to a
// detached node. But a *reactive* handler (e.g. `onPointerMove={dragging() ?
// a : b}`) re-registers in the same tick — cleanup (refcount → 0) then body
// (→ 1) — which must NOT tear down a live capture mid-drag. Defer, and
// release only if the object is still gone (a real unmount), not re-added.
queueMicrotask(() => {
if (!refCounts.has(object)) manager.releaseCaptured(object)
})
} else {
refCounts.set(object, current - 1)
}
}
}
// Drop any active capture on an object that genuinely leaves the registry, so a
// drag stops dispatching to a detached node. The registry defers this past a
// same-tick reactive re-register, so it fires only on a real unmount.
onCleanup(context.eventRegistry.onVacated(object => manager.releaseCaptured(object)))

return {
/**
Expand All @@ -82,7 +59,7 @@ export function createEvents(context: Context) {
* registry is not keyed by type).
*/
addEventListener(object: Meta<Object3D>, _type: EventName) {
return addToRegistry(object)
return context.eventRegistry.register(object)
},
}
}
3 changes: 2 additions & 1 deletion src/create-three.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import type { CanvasProps } from "./canvas.tsx"
import { createEvents } from "./create-events.ts"
import { Stack } from "./data-structure/stack.ts"
import { EventRegistry } from "./event-registry.ts"
import { frameContext, threeContext } from "./hooks.ts"
import { eventContext } from "./internal-context.ts"
import { useProps, useSceneGraph } from "./props.ts"
Expand Down Expand Up @@ -367,7 +368,7 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) {
},
canvas,
clock,
eventRegistry: [],
eventRegistry: new EventRegistry(),
get dpr() {
// Renderers without a pixel-ratio API (CSS2D/3D, SVG) didn't scale
// anything — reporting `1` is honest. Users who need the device's
Expand Down
56 changes: 56 additions & 0 deletions src/event-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Object3D } from "three"

/**
* The set of objects the pointer system raycasts, with refcounted membership: an
* object listening for several event types — or re-registering reactively — is
* listed in {@link objects} exactly once.
*
* Framework-agnostic: `register` returns a cleanup, so each pointer source wires it
* to its own lifecycle (the DOM source returns it up the chain; the XR plugin hands
* it to `onCleanup`). `onVacated` fires when an object genuinely leaves, after a
* deferred re-check that tolerates a same-tick reactive re-register — the DOM source
* uses it to release a pointer capture on a real unmount; XR doesn't subscribe.
*/
export class EventRegistry {
/** The objects to raycast. Read-only to callers; mutated only by {@link register}. */
readonly objects: Object3D[] = []
private counts = new WeakMap<Object3D, number>()
private vacatedListeners = new Set<(object: Object3D) => void>()

/** Register `object` (refcounted). Returns a cleanup that decrements, removing it at zero. */
register(object: Object3D): () => void {
const count = this.counts.get(object) ?? 0
if (count === 0) this.objects.push(object)
this.counts.set(object, count + 1)
return () => {
const current = this.counts.get(object)
if (current === undefined) return
if (current > 1) {
this.counts.set(object, current - 1)
return
}
this.counts.delete(object)
const index = this.objects.indexOf(object)
if (index !== -1) this.objects.splice(index, 1)
// A reactive handler re-registers in the same tick (cleanup → body): defer the
// vacated notice and fire only if the object is still gone — a real unmount, not
// a re-register that would otherwise tear down a live capture mid-drag.
if (this.vacatedListeners.size) {
queueMicrotask(() => {
if (!this.counts.has(object)) {
for (const listener of this.vacatedListeners) listener(object)
}
})
}
}
}

/**
* Subscribe to genuine vacates — an object whose last registration was cleaned up
* (and not re-registered the same tick). Returns an unsubscribe.
*/
onVacated(listener: (object: Object3D) => void): () => void {
this.vacatedListeners.add(listener)
return () => this.vacatedListeners.delete(listener)
}
}
2 changes: 0 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ export { Canvas, type CanvasProps } from "./canvas.tsx"
export { Entity, Portal, Resource } from "./components.tsx"
export { $S3C } from "./constants.ts"
export { createEntity, createT } from "./create-t.tsx"
export { createXR, useXR } from "./create-xr.tsx"
export type { XRContext, XRState } from "./create-xr.tsx"
export { useFrame, useLoader, useThree } from "./hooks.ts"
export { plugin } from "./plugin.ts"
export { hasPointerCapture } from "./pointer-capture.ts"
Expand Down
6 changes: 3 additions & 3 deletions src/pointers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export class Pointer {
// object — hover frozen, since `dispatch` never touches `this.hovered`).
if (this.captured) return this.dispatch("onPointerMove", nativeEvent, undefined, true)

const intersections = this.raycaster.cast(this.context.eventRegistry, this.context)
const intersections = this.raycaster.cast(this.context.eventRegistry.objects, this.context)
const props = this.context.props as Record<string, any>

// Phase #1 — Enter (bubble up; fire onPointerEnter for newly-hovered objects).
Expand Down Expand Up @@ -362,7 +362,7 @@ export class Pointer {
return
}

const intersections = this.raycaster.cast(this.context.eventRegistry, this.context)
const intersections = this.raycaster.cast(this.context.eventRegistry.objects, this.context)
const event = createThreeEvent(nativeEvent, { intersections }, extra)
if (capturable) this.attachCapture(event)
this.propagate(
Expand All @@ -375,7 +375,7 @@ export class Pointer {
/** Missable gesture: bubbled `onClick`/`onDoubleClick`/`onContextMenu` + `-Missed`. */
click(kind: "onClick" | "onDoubleClick" | "onContextMenu", nativeEvent: Event) {
const missedType = `${kind}Missed` as const
const registry = this.context.eventRegistry
const registry = this.context.eventRegistry.objects
const props = this.context.props as Record<string, any>
if (registry.length === 0 && !props[kind] && !props[missedType]) return

Expand Down
5 changes: 3 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
import type { WebGPURenderer } from "three/webgpu"
import type { CanvasProps } from "./canvas.tsx"
import type { $S3C } from "./constants.ts"
import type { EventRegistry } from "./event-registry.ts"
import type { EventRaycaster } from "./raycasters.tsx"
import type { Measure } from "./utils/use-measure.ts"

Expand Down Expand Up @@ -328,8 +329,8 @@ export interface Context {
canvas: HTMLCanvasElement
clock: Clock
camera: CameraKind
/** Objects carrying any pointer handler; raycast by the pointer system. */
eventRegistry: Object3D[]
/** The refcounted registry of objects carrying pointer handlers; raycast by the pointer system. */
eventRegistry: EventRegistry
raycaster: Raycaster | EventRaycaster
dpr: number
gl: Meta<ResolvedRenderer>
Expand Down
15 changes: 9 additions & 6 deletions src/create-xr.tsx → src/xr/create-xr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
onCleanup,
useContext,
} from "solid-js"
import type { Context } from "./types.ts"
import type { Context } from "../types.ts"

/**
* The in-scene XR state distributed by `createXR().Provider` and read by
Expand Down Expand Up @@ -57,7 +57,9 @@ export type XRContext = Pick<Context, "gl" | "render">
*/
type XRRenderer = {
setAnimationLoop(callback: ((time: number, frame?: XRFrame) => void) | null): void
xr: {
// Optional: a renderer cast to this slice may be a non-XR renderer at runtime
// (the guards below check before use), so the type must allow `xr` absent.
xr?: {
enabled: boolean
setSession(session: XRSession): Promise<void>
addEventListener(type: string, listener: () => void): void
Expand Down Expand Up @@ -99,7 +101,7 @@ export function createXR() {
setPresenting(false)
setSession(undefined)
gl.setAnimationLoop(null) // edge 2: stop three re-driving render post-exit
gl.xr.enabled = false
xr.enabled = false
}
xr.addEventListener("sessionstart", onStart)
xr.addEventListener("sessionend", onEnd)
Expand Down Expand Up @@ -127,7 +129,8 @@ export function createXR() {
throw new Error("S3: createXR().enter() called before <Canvas ref={xr.connect}> connected")
}
const gl = ctx.gl as unknown as XRRenderer
if (!gl.xr) {
const xr = gl.xr
if (!xr) {
throw new Error("S3: the active renderer has no xr manager")
}

Expand All @@ -136,8 +139,8 @@ export function createXR() {

// Edge 1: setAnimationLoop(render) BEFORE setSession (WebGPU snapshots here).
gl.setAnimationLoop(ctx.render)
gl.xr.enabled = true
await gl.xr.setSession(xrSession)
xr.enabled = true
await xr.setSession(xrSession)
setSession(xrSession)
return xrSession
}
Expand Down
Loading
Loading