feat(events)!: pointer events and the raycaster move out of core into a plugin#78
Draft
bigmistqke wants to merge 30 commits into
Draft
feat(events)!: pointer events and the raycaster move out of core into a plugin#78bigmistqke wants to merge 30 commits into
bigmistqke wants to merge 30 commits into
Conversation
Pin what actually fires when the user interacts, on a real scene through the real raycaster — alongside the existing tests that mostly pin which objects register.
Raycasting reads matrixWorld, which is refreshed per render frame; a synthetic fireEvent runs before the first frame, so any test that depends on an object's position awaits waitTillNextFrame() first (otherwise every mesh hit-tests as if at the origin). Origin-only and empty-scene tests don't need it.
Covers:
- any handler makes an object a target for every gesture (a click lands on an onWheel-only or onPointerMove-only mesh), so the canvas *Missed does not fire;
- clicking one object fires a different object's onClickMissed (the decentralized-deselect notification), while the hit object's own does not;
- a rear object behind a non-blocking front one receives each of the seven propagating gestures, and stopPropagation on the front stops it;
- raycastable={false} takes a mesh out of hit-testing and the ray passes through to what is behind;
- the canvas onClick on a void receives an event whose object is undefined;
- a captured pointer's up/move still reach the canvas handler unless the captured object stops propagation; hover is frozen while captured; releasePointerCapture() in onPointerUp restores normal delivery; enter/leave carry no stopPropagation.
Two behaviours are marked it.fails — each asserts the intended behaviour, stays green while broken, and turns red the day it is fixed:
- clicking empty space fires both the canvas onClick and onClickMissed; a void should deliver one signal, not both;
- a group's onClickMissed should not fire when its own child is clicked, but a child that stops propagation leaves the group counted as missed.
…handler, drop canvas-level 3D handlers Replace the per-gesture onClickMissed/onDoubleClickMissed/onContextMenuMissed with r3f's single onPointerMissed. Per-object: fires on every registered object the click did not land on (hit, or an ancestor of a hit) — the "not-me", computed from the raycast hit-closure so stopPropagation can no longer widen the missed set (fixes Surprise B). Canvas-level onPointerMissed is the void signal, firing only on a total miss. Remove the bespoke canvas-level 3D handler family so onPointerMissed is the only canvas-level pointer handler, and drop the old complement-set re-raycast.
…s-level handler docs Replace the *Missed family with the single onPointerMissed across the API reference, tour, and snippet (renamed 04-click-missed → 04-pointer-missed). Object-level is the not-me (fires when a click lands on something other than this object or its descendants); canvas-level is the void signal (total miss only). Remove the now-invalid canvas-level 3D handler material: the canvas-propagation example's Canvas onClick step, the capture escape-hatch wording, and the old stopPropagation-blocked miss example.
…ointerMissed model
Plugins have no Solid context, so the frame loop was unreachable from Context. useFrame keeps working over the same listener registry.
The disposal half of the addFrameListener test captured before/after tick counts synchronously, with no await between dispose() and the second count. waitFor's first check then already found the counts equal, so the assertion passed even if dispose() were a no-op. The test now captures waitTillNextFrame from test(), waits ten real animation frames after dispose() to let any already-scheduled tick land, then waits ten more frames and asserts the tick count didn't move across that second window. Verified by temporarily stubbing dispose to a no-op: the test now fails (15 vs 25 ticks) as expected, and passes again with the real dispose call restored.
…nvas contributions A plugin can now carry a module-level token, an install(context) run once per context under the canvas owner, and a canvas(context) contributing canvas-level props. Two instances of one engine install exactly once. initializePlugin runs fn via runWithOwner(this.owner, fn) rather than a fresh getOwner() call — initializePlugin is invoked from inside a per-element render effect, so an ambient getOwner() there would resolve to that element's own reactive scope and tie the plugin's cleanup to whichever element happens to trigger the (deduped) install first. this.owner is the Canvas's owner, captured once at context creation, so cleanup survives an individual element unmounting and only fires when the canvas itself is disposed. useProps's context parameter widens from a narrow Pick to the full Context, since installing a plugin needs initializePlugin and every caller already passes the full context. Added a second test that mounts a plugin-consuming element inside a Show, unmounts just that element while the canvas stays mounted, and asserts the plugin's cleanup has not run — proving install's cleanup is scoped to the canvas, not to the first element that happened to mount.
Canvas is generic over its plugin tuple and delivers each contributed canvas prop to the engine that declared it. Colliding props last-win, with a dev warning. Eager install here pairs with the lazy element-driven install; the token dedups them. CanvasProps drops CanvasEventHandlers — the canvas no longer has any built-in pointer props of its own; every canvas-level prop now comes from a plugin's canvas() contribution. Also defines process.env.DEV in vitest.config.ts, mirroring tsup's dev-variant build define. The raw source under test goes through Vite's own transform, not tsup's esbuild define step, so process.env.DEV would otherwise throw ReferenceError: process is not defined the moment a dev-gated branch runs in a real-browser test.
Sugar over the plugins prop for the single-namespace case. The prop stays overridable, so co-existence still works.
Core no longer contains an event system. The registry, the Pointer dispatcher, the DOM pointer manager and the raycast strategies move behind a plugin; pointerEvents() installs them through the plugin statics. Element handlers ride the existing contributed-prop path, so the isEventType regex is deleted rather than replaced. The registry now lives on the engine rather than on the Context, which is what lets several engines co-exist on one canvas: an object only ever enters the registry of the engine whose plugin contributed its handler prop. Engine state is keyed per Context, so one plugin instance can serve several canvases. The eager plugin install and the canvas-level contributed props move from the Canvas component down into createThree, so every entry point into a scene installs plugins identically — the Canvas component and the test()/TestCanvas helpers, which call createThree directly, and which the moved suites need in order to receive the canvas-level onPointerMissed. PluginPropsOf gains the same no-plugins guard ContributedKeys already carries. Without it a plugin-less createT(THREE) degrades into an index signature that accepts every prop, so onClick would type-check and then silently do nothing — the dead handler this boundary exists to turn into a compile error.
Contributed props (onClick and friends) used to be typed only for a plugin array written inline in the createT call. A hoisted array, such as const plugins = [pointerEvents()] followed by createT(THREE, plugins), lost its literal tuple type on assignment, so onClick was wrongly rejected on T.Mesh even though an engine was installed. The type that resolves a plugin's contributed props read any as a real return type instead of recognizing it as the fallback for 'no concrete plugin was inferred'. A length check on the plugin tuple worked around this for the no-plugin case but also rejected the hoisted case, since a hoisted array's type still reports a plain number for its length even when its elements are known precisely. Added an IsAny check that treats an any return value as 'no plugin inferred', and removed the length-based workarounds from both the prop resolver and its sibling that computes which base props a plugin overrides. Hoisted plugin arrays now type onClick correctly, while a plugin array with no elements inferred at all still leaves onClick a type error, and passing a bogus prop name is still rejected. Made TestCanvas generic over its plugin tuple, matching Canvas and the test() helper, so an engine's contributed canvas props (such as onPointerMissed) typecheck through TestCanvas the same way they already worked at runtime. Added a development-only warning when a second pointerEvents() instance is installed into a context that already has one installed, if its raycaster option differs from the one already in effect. Only the first instance's install actually runs, so a second instance's raycaster option was silently ignored with no signal that anything was dropped; the engine now remembers which raycaster it was installed with so this can be compared and reported.
The DEV warning for a second pointerEvents() instance's ignored raycaster
option previously lived inside installEngine's dedup guard, and the canvas
static called installEngine directly to make that warning reachable. That
routed an owner-sensitive install around Context.initializePlugin's
runWithOwner discipline - installEngine ends in onCleanup(engine.connect()),
which needs to run under the Canvas's owner, not whichever element's
render-effect scope happens to call it first. It worked only by accident of
call ordering, and it still didn't cover a second instance declared on the
namespace (createT(THREE, [pointerEvents({raycaster})])), since useProps
never calls plugin.canvas.
installEngine is back to a plain early-return guard with no warning logic,
reachable only through initializePlugin (the canvas static no longer calls
it). The warning is now a separate pure-read helper, warnOnIgnoredRaycaster,
which reads the installed engine's raycaster without touching the engines
map. The canvas static calls it before returning its contributed props.
Documented the underlying rule on pointerEvents()'s raycaster option: the
first instance to install per canvas wins, so configure the raycaster on
the instance listed on <Canvas plugins> - this is also what covers the
namespace-only case the warning itself can't reach.
…aster Core holds and exposes a raycaster for useThree() and the Canvas prop, and never calls it. The event-raycaster strategies belong to the engine.
…g raycaster
Core builds a plain THREE.Raycaster for <Canvas raycaster={{ ... }}> (the config-object form) and applies the user's near/far/params/layers to that instance. The pointer-events engine only adopts core's raycaster directly when it already has the screen-raycaster shape (setCursor and cast); a plain Raycaster never does, so the engine was quietly building its own fresh CursorRaycaster with none of that configuration. The result: far, near, and the rest of the raycaster prop had no effect on picking at all.
installEngine now applies the canvas's raycaster prop to its fallback CursorRaycaster too, reactively, through the same useProps helper core already uses on its own raycaster instance. This only runs when the engine actually had to build its own raycaster, and only for the config-object form of the prop — a raycaster instance (screen-shaped or not) keeps its own values and is left alone, matching how core already treats the instance form.
Adds tests/events/raycaster-config.test.tsx, covering the full matrix: a props-object raycaster config now excludes and includes objects by far/near as expected, a screen-raycaster instance is still used directly by the engine, and the no-raycaster-prop default still picks normally.
Last-wins was already the runtime rule; it was silent and undocumented.
No engine means no listeners and no onClick prop. Two engines keep disjoint registries. Two instances of one engine install once and dispatch once.
…er filter
The "token stability" describe block had one test that asserted on the
end-to-end guarantee (one manager, one dispatch across two pointerEvents()
instances) but nothing that isolated the token layer itself. That
composite test passes even if POINTER_EVENTS_TOKEN in src/events/index.ts
regresses to a per-call Symbol(), because installEngine's own
engines.has(context) guard independently prevents a double install. Add a
direct assertion that pointerEvents() returns the same module-level token
across instances, which fails immediately on that regression. Comments on
both tests now say which guard layer each one actually exercises.
Also tighten the listener filter in the "no engine, no events" test: it
used a startsWith("pointer") heuristic plus a short list, which missed
lostpointercapture and so only checked 9 of the 10 listener names
DOMPointerManager.connect registers. The filter now matches the full list
of names read from src/events/pointer-managers.ts.
The pointer-event engine at src/events/index.ts is now a published entry point. tsup.config.ts builds it under the events name (matching the index/testing entries), and package.json exposes it as solid-three/events with the same solid/development/import conditions as the existing testing subpath, plus a matching typesVersions entry.
…t demos The core pointer-event system moved out into a plugin, so a T.Mesh only gets onClick, onPointerEnter, onPointerDown, and the rest of the pointer handlers once pointerEvents() from solid-three/events is installed on the T namespace used in that demo. The site's pointer-event snippets (click, hover, stop-propagation, drag, pointer-missed) and two snippets that use pointer handlers incidentally (the look-at plugin demo, the letter-drop gallery piece) still built their T namespace with createT(THREE) alone, so onClick and friends were not valid props on T.Mesh anymore. Each of these snippets now imports pointerEvents from solid-three/events and adds it to its createT (or createT.withCanvas, for the one demo that also reads onPointerMissed off the Canvas) call. The drag snippet's hasPointerCapture import moves from solid-three to solid-three/events, since it no longer lives on core. None of the demonstrated behavior changes — only the imports and the plugin list. Also fixes package.json: the solid-three/events subpath export's types path pointed at dist/events/index.d.ts, a file the build never produces (the actual output is dist/events.d.ts). This is the same copy-pasted mistake the solid-three/testing export already had (still present, not touched here since nothing currently imports that subpath from TypeScript source). Note: pnpm lint:types is still not clean after this change. Installing pointerEvents() alongside createT now surfaces a separate, pre-existing build defect: the package's three publishable entry points (solid-three, solid-three/events, solid-three/testing) are built as three independent declaration bundles, so each one declares its own private copy of shared internal types instead of sharing one. Two independently declared copies of the same type are never assignable to each other, so any plugin from solid-three/events fails to typecheck against solid-three's createT. This is a build-tooling problem, not a snippet problem, and is out of scope for this change. Full evidence and a suggested fix direction are in .superpowers/sdd/task-10-snippets-report.md.
… brand The three publishable entry points — `solid-three`, `solid-three/events` and `solid-three/testing` — were each rolled up into their own declaration bundle by `tsup`'s `dts` option. Every bundle therefore carried a private copy of each shared internal type, including `declare const $S3C: unique symbol`. The identity of a `unique symbol` is per-declaration, so `solid-three/events`' `Context`, `Meta` and `Plugin` were unrelated to `solid-three`'s identically-spelled ones. For a consumer that meant the event engine could not be used at all: `createT(THREE, [pointerEvents()])` did not typecheck, every prop the engine contributes (`onClick`, `onPointerMove`, …) went missing from `T.Mesh`, and any co-plugin listed alongside it had its own contributed props corrupted too — a plugin's `lookAt` prop collapsed back onto `Object3D`'s native overloaded method. Declarations are now emitted by a single `tsc --emitDeclarationOnly` pass over the whole source tree, with the module structure preserved, so `$S3C` is declared exactly once in `types/constants.d.ts` and every entry point imports that one symbol. Each `exports` block's `types` points at the matching file under `types/`, which also fixes the two entries that pointed at paths the build never produced (`./dist/events/index.d.ts`, `./dist/testing/index.d.ts`). `dist/` is now JavaScript only. `consumer/` is a typecheck fixture that reads the package the way a dependent does — `solid-three` and `solid-three/events` resolve through the `exports` map onto the built declarations, with no path alias and no `src/`. It has its own tsconfig that deliberately does not set `allowImportingTsExtensions`, so it cannot see library internals. `pnpm lint:types` runs it, and it fails against the old per-entry-point declaration bundles. `prepublishOnly` now builds before it lints, because the type check needs the built artefacts to exist.
eslint flagged the `as object` casts as unnecessary while tsc rejected `in` on a possibly-primitive `{}`. Narrowing through the instanceof check satisfies both.
Core ships no pointer-event system, so every pointer example now installs one explicitly and the API reference says plainly that onClick does not exist on T.Mesh until an engine is installed — a compile error rather than a handler that silently never fires. useThree().raycaster is documented as the plain THREE.Raycaster core holds and never casts with. The raycaster classes and the event types move to the solid-three/events import path. CONTEXT.md gains an event-engine glossary entry, and eventRegistry moves off Context and onto the engine.
Every entry point (`solid-three`, `solid-three/events`, `solid-three/testing`) was built as its own standalone bundle, so each one inlined a private copy of the core modules they share. A copied module carries copied module-level state: `dist/events.js` had its own `Symbol("solid-three")` for the `$S3C` brand, its own `engines` WeakMap, its own caches. Core's `meta()` out of `dist/index.js` therefore branded objects with one symbol while the engine's `getMeta()` out of `dist/events.js` looked up a different one, read `undefined`, and never registered the object. Pointer events silently never fired for anyone installing the published package.
Build all three entry points together instead, once per dev/production/solid variant, with code splitting on. What they share is hoisted into a chunk that each of them imports, so there is exactly one copy of every core module at runtime. The variants and the `exports` conditions are unchanged: the solid variant still ships its JSX uncompiled for the consumer's compiler to handle.
The whole suite stayed green through all of this because every test imports from `src/`, which is one module graph by construction and so can never see a defect that only appears once the code is split into shipped bundles. `tests/published/consumer.test.tsx` closes that gap: it resolves `solid-three` and `solid-three/events` the way a dependent does — through the `exports` map, onto the built files — mounts a mesh with a click handler and asserts the handler fires. It fails against a build with the duplication and passes without it. It needs a build first, so it runs under its own script, `pnpm test:published`, which builds; the existing `pnpm test` excludes it.
The same duplication, in the declarations rather than the JavaScript, is what `tsconfig.build.json` already works around by emitting all types in one pass.
Validates the event boundary against an engine we do not own. `pmndrsEvents()` wraps `@pmndrs/pointer-events` v6.6.30 (the package TresJS v5 uses) and reaches core through nothing but the published plugin surface: `token` plus `install` for its one-time per-canvas setup, `Context.addFrameListener` to pump the `update()` that flushes its batched events, and the contributed-method path to put element handlers onto three's `EventDispatcher`. No file under `src/` outside the new engine changed. It is a deliberate foil for the reference engine. It contributes no canvas prop, because pmndrs materialises the void as a real object: `getVoidObject(scene)` is a `Mesh` whose parent is the scene without ever being added to it, so it bubbles while never being traversed or rendered, and a miss is simply an intersection whose object is that mesh. "Clicked empty space" is therefore an ordinary object-level listener, not a canvas-level signal — which shows the canvas-prop channel is optional rather than load-bearing. It also needs less of the boundary than the reference engine does: it keeps no registry, so it never reads an element's mount-site context. The shared click helper turns out to have been shaped by the reference engine. It fired a bare `click` MouseEvent, which is enough for an engine that listens for the native `click` but invisible to pmndrs, which hears only pointerdown and pointerup and synthesises the click itself; pmndrs also recomputes a live pointer's intersection on pointermove, so a click at a new location with no intervening move resolves against the previous hit. The helper now fires the sequence a real browser fires — pointermove, pointerdown, pointerup, click — so one dispatch mechanism drives both engines. Every pre-existing test passes against the fuller sequence. Three browser tests cover it: a click dispatching through the public boundary, a miss reported on the void object, and both engines installed on one canvas each dispatching through its own, with pmndrs' process-global `Object3D.prototype` capture patching leaving the reference engine's behaviour intact. Not exported from package.json. Whether this engine ships publicly is a separate decision.
…op loose plugins swallowing every prop
Two type-level holes, both of which let something compile that does not work.
The Canvas returned by createT.withCanvas was a plain non-generic function whose props were pinned to the bound plugin tuple, so its plugins prop only accepted a tuple of exactly the bound plugins' own types. Both uses the JSDoc and the design advertise — overriding the default with a different engine, and extending it to several — failed to compile. The Canvas is now generic over its own plugin tuple, defaulting to the bound one: absent, the prop has no inference site and falls back to the bound engines; present, the canvas is typed by whatever was actually passed.
The override test could not see this, because it overrode one fakeEngine with another and the two have identical structural types, so the override typechecked by accident. It now overrides with an engine contributing a differently-named canvas prop, and asserts the bound default is not installed at all. A second test pins that extending to [bound, other] typechecks and delivers both engines' canvas props.
The second hole is the sibling of the IsAny guard. Mapping over an index signature produces an index signature, so a plugin whose contributed methods are typed Record<string, (value: any) => void> — the shape PluginStatics itself declares for canvas — degraded the element and canvas prop types into { [x: string]: any }, which accepts every prop and then silently drops it, the exact dead-handler failure the event boundary exists to abolish. pointerEvents() escapes it only by spelling its canvas static out concretely; a third-party engine author annotating against the published contract would not. PluginPropsOf and CanvasPropsOf now yield {} for such a plugin, so it contributes nothing rather than everything, and an unknown prop stays a compile error. Concrete plugins are unaffected, including the hoisted-array case, which a length-based guard would have broken.
tests/published/consumer.test.tsx is the only suite that can see a defect existing solely in the built bundles — every other suite imports src/, which is one module graph by construction. It exists because such a defect shipped: each entry point was bundled standalone, so dist/events.js carried a second copy of core and its own $S3C symbol, core's meta() branded objects with one and the engine's getMeta() read the other, and pointer events silently never fired for anyone consuming the package while all 241 tests stayed green. That guard ran only in prepublishOnly, and the workflow ran only pnpm test, which excludes tests/published. A pull request reintroducing the duplication would have gone green. It now runs in the workflow too, through test:published, which rebuilds first so it can never assert against a stale dist/.
…ublished types, correct createThree's docstring Context, PluginStatics and FrameListener were reachable only under the S3 namespace, though every install(context) and canvas(context) signature an engine author writes names Context, and a frame-driven engine names FrameListener. For a release whose point is third-party engines, they belong next to Plugin at the top level. The declaration build's include of src swept in src/events-pmndrs, which is deliberately unshipped — no exports entry, no dist bundle. It emitted types/events-pmndrs/index.d.ts, which files: ["types/**"] published and which imports @pmndrs/pointer-events, a devDependency absent from any consumer's install: a dangling reference in the tarball, for code nothing can reach. The build tsconfig now excludes it. createThree's docstring still claimed it sets up an event system. That is what this branch removed: core ships no event engine, and pointer events arrive only when an engine plugin is installed through the plugins prop.
Core kept a plain `THREE.Raycaster` it never cast with, exposed as `useThree().raycaster`, configured via `<Canvas raycaster={…}>` and overridable through a `setRaycaster` stack. With the default config that meant two raycasters: core's, which nothing consulted, and the engine's `CursorRaycaster`, which did the actual picking. `useThree().raycaster.far = 20` silently did nothing, and the engine had to reach back into `context.props.raycaster` to re-apply the canvas's config onto the raycaster that really casts.
The raycaster now lives wholly in the pointer-events plugin. Core loses `Context.raycaster`, `Context.setRaycaster`, the raycaster stack, and the `<Canvas raycaster>` prop — it does no picking, so it owns nothing to pick with. There is exactly one raycaster per canvas.
`pointerEvents({ raycaster })` takes either form of the old canvas prop: a `ScreenRaycaster` instance (a whole ray strategy, e.g. `new CenterRaycaster()`), or a config object of raycaster properties (`{ far: 10, near: 1 }`) applied to the engine's own `CursorRaycaster`. The option is read once, at install.
`useRaycaster()`, exported from `solid-three/events`, returns the raycaster that actually picks, so mutating it changes what gets picked. On a canvas with no engine it throws rather than handing back a raycaster nothing casts with.
The boundary test that asserted core keeps a raycaster and never calls it is replaced by the claim that core has no raycaster at all — a claim that cannot rot.
A subtree can now override the raycaster and have the previous one restored when it unmounts. The engine's own raycaster — from `pointerEvents({ raycaster })`, or the default `CursorRaycaster` — sits at the bottom of the stack; `setRaycaster(next)` pushes and returns a disposer that pops. This mirrors the push/pop-with-disposer contract core's `setCamera` uses, and reuses core's `Stack`.
The engine reads the TOP OF THE STACK at cast and aim time rather than holding a raycaster it captured at install. `PointerEventsEngine` hands `DOMPointerManager` an accessor, and every `Pointer` resolves the raycaster afresh on each cast, so an override genuinely changes what gets hit. A raycaster captured once at install is exactly the bug this avoids: the pointer system would keep picking with the old raycaster while the hook happily handed out the new one.
`useRaycaster()` now returns `{ raycaster, setRaycaster }`. `raycaster` is an accessor rather than a getter on the returned object, so destructuring the result keeps it live instead of snapshotting the raycaster and silently losing reactivity. It still throws when no engine is installed on the enclosing Canvas.
`Pointer` accepts either a raycaster or an accessor for one — the same value-or-accessor shape `Stack` itself takes — so a source with a single fixed ray strategy, such as an XR controller, keeps passing its raycaster directly.
BREAKING CHANGE: `useRaycaster()` returns `{ raycaster, setRaycaster }` instead of the raycaster itself. Read it with `useRaycaster().raycaster()`.
The declaration build typechecks src for the first time, which surfaced that process.env.DEV had no declaration behind it. It worked locally only because @types/node happened to be present transitively, and CI's clean install does not have it. The repo's vite plugins and build configs are genuine Node code and were already depending on these types, so declaring the dependency is the honest fix.
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
solid-three's pointer events —
onClick,onPointerEnterand friends, written as props on a mesh — were built into the library itself. One hit-testing implementation, one set of semantics, no way to change either.The semantics are contested. Take the simplest question: what should happen when you click on empty space?
@pmndrs/pointer-events, the standalone package TresJS (the Vue one) builds on, puts a real invisible object in the scene standing for "the void" — so clicking nothing is an ordinary click on that object.Three reasonable answers. This repository has two more still open: a per-gesture family of "nothing was hit" handlers (#75), and reporting the miss through the event object on the canvas (#76). Building any one of them into the library forces an unsettled choice on everyone.
Some applications need something else entirely. Per-frame raycasting, GPU colour picking, screen-space nearest-neighbour hit-testing — none of these can be expressed by something that raycasts on each pointer event and bubbles the result up the scene graph. Wanting one of them today means giving up on solid-three's events and writing your own raycaster.
What changes
Pointer events become a plugin. An event engine is a plugin that implements them end to end: how a pointer hit is found, how it propagates, and which handler props exist. The library ships one,
pointerEvents(), and installs none by default.pointerEvents()is what was previously built in. Same raycasting, same propagation, same handlers.Forgetting to install it cannot fail silently. Plugins contribute typed props, so without it,
onClickis not a prop ofT.Meshand TypeScript rejects it where you wrote it. That is why nothing is installed by default: the accident a default would guard against is caught by the compiler instead.Migration
Existing code will not compile until an engine is installed. Every change below is a compile error, not a silent change in behaviour.
Install it
That restores every handler at once:
onClick,onDoubleClick,onContextMenu,onPointerDown,onPointerUp,onPointerMove,onPointerEnter,onPointerLeave,onWheel,onPointerMissed.If you do not use the canvas-level
onPointerMissed, this is the only change you need — keep importingCanvasfromsolid-threeas before.Canvas-level
onPointerMissed<Canvas>has no pointer props of its own any more.onPointerMissedexists only because an engine contributes it, so the canvas has to know about the engine too.createT.withCanvasiscreateTplus a matchingCanvas:The raycaster
The raycaster belongs to the engine, because the engine is what casts with it. Configure it there; read it with
useRaycaster().Replacing it for part of the scene —
setRaycasterpushes onto a stack and returns a disposer that pops it:A pushed raycaster is used for every cast that follows, so an override changes which object a click hits, and popping it restores the one underneath.
tests/events/raycaster-stack.test.tsxcovers both directions.Behaviour change: on the released version an override changed what
useThree().raycasterhanded back but not what the pointer system cast with, so it had no effect on picking. Code written against that will now see clicks land on different objects.Imports that moved
Same behaviour, now imported from
solid-three/eventsinstead ofsolid-three, because they belong to the engine:CursorRaycaster,CenterRaycaster,Pointer,hasPointerCapture,ThreeEvent,EventHandlers,EventName.Removed
eventRegistry— the list of objects carrying handlers — is gone from whatuseThree()returns. Each engine keeps its own now, which is what lets two run on one canvas without seeing each other's objects, so there is no shared list to hand back.If you read it to find the interactive objects, keep your own set instead:
A second engine, wrapping an external package
A second engine wrapping
@pmndrs/pointer-eventsplugs into the same public API with no changes to the library. It lives insrc/events-pmndrs, is excluded from the published build, and is exercised only by tests.It earns its place by being different: its "clicked nothing" is that invisible object in the scene, not a canvas prop. Its tests assert that a click dispatches through nothing but the public API, that a miss arrives on the void object, and that it coexists with the built-in engine without disturbing it.
Two engines can run on one canvas, because each owns its own registry: a mesh dispatches through the engine belonging to the
Tthat created it.Performance
No scenario regressed beyond measurement noise.
Median frame time in milliseconds, over a 120-frame window after 30 warmup frames. Three passes per version, alternating, taking the minimum, since noise only ever adds time. Spread is the range across those passes, as a percentage — the noise a difference has to beat to mean anything.
Every difference is smaller than its spread except
event-propagation ×10. A repeat measurement of that scenario places it inside the noise as well.Two limits worth stating. The remaining scenarios sit at the display's refresh floor under software rendering, where no difference can show at all — so this rules out a large regression, not a few-percent one. And the benchmark harness is not in this repository, so these numbers cannot be reproduced from this pull request alone.
Follow-ups
@pmndrs/pointer-eventsdoes — so two of them can still fight over it.solid-three/xr, and needs nothing from this pull request.