From 605783efcdb24f8f308f9a8db7491661faacd91e Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 15:32:09 +0200 Subject: [PATCH 01/10] feat(plugins): minimal plugin seam foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the input-agnostic plugin scaffolding on top of #64's Pointer engine: - Context.owner + initializedPlugins - Plugin type; createT(catalogue, plugins) + composition - creation-gated plugin setup (initPlugins, src/plugin.ts) — off the per-attach scene-graph hot path (applySceneGraph stays byte-identical to #64) - public Pointer.dispatch(name) + Pointer/Plugin exports Perf-validated against three-bench (fractal-5x5 at parity). This is an interim seam; the #37 plugin API (inferred types, class-filter, meta.ctx) supersedes the Plugin shape next. --- src/components.tsx | 11 +++++-- src/create-t.tsx | 15 +++++++-- src/create-three.tsx | 3 ++ src/index.ts | 3 +- src/plugin.ts | 20 +++++++++++ src/pointers.ts | 15 ++++++--- src/types.ts | 16 ++++++++- tests/core/plugins.test.tsx | 66 +++++++++++++++++++++++++++++++++++++ 8 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 src/plugin.ts create mode 100644 tests/core/plugins.test.tsx diff --git a/src/components.tsx b/src/components.tsx index da631642..3854cadf 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -10,8 +10,9 @@ import { } from "solid-js" import { Loader, Object3D } from "three" import { threeContext, useLoader, useThree, type UseLoaderOptions } from "./hooks.ts" +import { initPlugins } from "./plugin.ts" import { useProps } from "./props.ts" -import type { Constructor, LoaderData, LoaderUrl, Meta, Overwrite, Props } from "./types.ts" +import type { Constructor, LoaderData, LoaderUrl, Meta, Overwrite, Plugin, Props } from "./types.ts" import { type InstanceOf } from "./types.ts" import { autodispose, hasMeta, isConstructor, meta, withContext, type LoadOutput } from "./utils.ts" @@ -78,6 +79,8 @@ type EntityProps> = Overwrite< { from: T | undefined children?: JSXElement + /** Plugins scoped to this element (see Plugin / createT). */ + plugins?: Plugin[] }, ] > @@ -91,7 +94,9 @@ type EntityProps> = Overwrite< * @returns The Three.js object wrapped as a JSX element, allowing it to be used within Solid's component system. */ export function Entity>(props: EntityProps) { - const [config, rest] = splitProps(props, ["from", "args"]) + // `plugins` is read off `childMeta.props` by the scene-graph plugin trigger; + // split it out of `rest` so it isn't applied to the three instance as a property. + const [config, rest] = splitProps(props, ["from", "args", "plugins"]) const instance = createMemo(() => { const from = config.from if (!from) return undefined @@ -103,6 +108,8 @@ export function Entity>(props: EntityProp ) as Meta }) useProps(instance, rest) + // Creation-gated plugin setup (see createEntity): only when this element opts in. + if (config.plugins?.length) initPlugins(useThree(), config.plugins) return instance as unknown as JSX.Element } diff --git a/src/create-t.tsx b/src/create-t.tsx index 143fb4a0..49fad5a7 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -1,6 +1,8 @@ import { createMemo, type Component, type JSX } from "solid-js" +import { useThree } from "./hooks.ts" +import { initPlugins } from "./plugin.ts" import { useProps } from "./props.ts" -import type { Props } from "./types.ts" +import type { Plugin, Props } from "./types.ts" import { autodispose, meta } from "./utils.ts" /**********************************************************************************/ @@ -9,7 +11,10 @@ import { autodispose, meta } from "./utils.ts" /* */ /**********************************************************************************/ -export function createT>(catalogue: TCatalogue) { +export function createT>( + catalogue: TCatalogue, + plugins: Plugin[] = [], +) { const cache = new Map>() return new Proxy<{ [K in keyof TCatalogue]: Component> @@ -24,7 +29,7 @@ export function createT>(catalogue: T if (!constructor) return undefined /* Otherwise, create and memoize a component for that constructor. */ - cache.set(name, createEntity(constructor)) + cache.set(name, createEntity(constructor, plugins)) } return cache.get(name) @@ -41,6 +46,7 @@ export function createT>(catalogue: T */ export function createEntity( Constructor: TConstructor, + plugins: Plugin[] = [], ): Component> { return (props: Props) => { const memo = createMemo(() => { @@ -54,6 +60,9 @@ export function createEntity( } }) useProps(memo, props) + // Plugin setup is creation-gated, not per-attach: a no-plugin namespace does + // a single closure-length check and never touches the scene-graph hot path. + if (plugins.length) initPlugins(useThree(), plugins) return memo as unknown as JSX.Element } } diff --git a/src/create-three.tsx b/src/create-three.tsx index f7ea7b83..37f304b3 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -5,6 +5,7 @@ import { createRenderEffect, createResource, createRoot, + getOwner, untrack, mergeProps, onCleanup, @@ -355,6 +356,8 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { get bounds() { return measure.bounds() }, + owner: getOwner(), + initializedPlugins: new Set(), canvas, clock, eventRegistry: [], diff --git a/src/index.ts b/src/index.ts index f849771e..082053e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,11 +5,12 @@ 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 { Pointer, createThreeEvent, type PointerRaycaster } from "./pointers.ts" export { useProps } from "./props.ts" export * from "./raycasters.tsx" export * as S3 from "./types.ts" // Direct re-exports of types that users commonly need at the top level. // `Register` is augmentable from `declare module "solid-three"` (see its // JSDoc); `Renderer` and `ResolvedRenderer` show up in advanced typings. -export type { Register, Renderer, ResolvedRenderer } from "./types.ts" +export type { Plugin, Register, Renderer, ResolvedRenderer } from "./types.ts" export { autodispose, getMeta, hasMeta, load, meta } from "./utils.ts" diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 00000000..6748acb4 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,20 @@ +import { runWithOwner } from "solid-js" +import type { Context, Plugin } from "./types.ts" + +/** + * Run each plugin's `setup` once per context, in the Canvas owner. + * + * Called from element creation (`createEntity` / ``) — gated by a cheap + * `plugins.length` check there — NOT from the per-attach scene-graph path, so a + * no-plugin app pays nothing. Dedup is per-context (`context.initializedPlugins`), + * so the first element carrying a plugin runs its setup and later ones skip it. + * `runWithOwner(context.owner, …)` ties the setup's lifetime to the Canvas, so its + * `onCleanup` fires on Canvas unmount, not when the triggering element unmounts. + */ +export function initPlugins(context: Context, plugins: Plugin[]) { + for (const plugin of plugins) { + if (context.initializedPlugins.has(plugin)) continue + context.initializedPlugins.add(plugin) + runWithOwner(context.owner, () => plugin.setup?.(context)) + } +} diff --git a/src/pointers.ts b/src/pointers.ts index 2d514775..1303ced3 100644 --- a/src/pointers.ts +++ b/src/pointers.ts @@ -134,17 +134,22 @@ export class Pointer { } down(nativeEvent: Event) { - this.dispatchBubbled("onPointerDown", nativeEvent) + this.dispatch("onPointerDown", nativeEvent) } up(nativeEvent: Event) { - this.dispatchBubbled("onPointerUp", nativeEvent) + this.dispatch("onPointerUp", nativeEvent) } wheel(nativeEvent: Event) { - this.dispatchBubbled("onWheel", nativeEvent) + this.dispatch("onWheel", nativeEvent) } - /** Shared body for the down/up/wheel "default" gestures (bubble + canvas-level). */ - private dispatchBubbled(handler: "onPointerDown" | "onPointerUp" | "onWheel", nativeEvent: Event) { + /** + * Bubble a "default"-style gesture to an arbitrary handler name (plugin-extensible: + * the built-in sources fire `onPointerDown`/`onPointerUp`/`onWheel`; a plugin source + * can fire its own names, e.g. `onXRSelect`). Bubbles up the hit chain honoring + * `stopPropagation`, then fires canvas-level if unstopped. + */ + dispatch(handler: string, nativeEvent: Event) { const intersections = this.raycaster.cast(this.context.eventRegistry, this.context) const event: any = createThreeEvent(nativeEvent, { intersections }) for (const intersection of intersections) { diff --git a/src/types.ts b/src/types.ts index 08781038..5e685c92 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { Accessor, JSX } from "solid-js" +import type { Accessor, JSX, Owner } from "solid-js" import type { Clock, ColorRepresentation, @@ -230,8 +230,22 @@ export type ResolvedRenderer = Register extends { renderer: infer R } ? R : WebG /* */ /**********************************************************************************/ +/** + * A composable extension. Its `setup` runs once per `Context` (deduped, in the + * Canvas owner) the first time an element carrying it attaches to the scene. + * Input-agnostic — core never inspects what `setup` does. + */ +export type Plugin = { + name?: string + setup?: (context: Context) => void +} + export interface Context { bounds: Measure + /** The Canvas's reactive owner — plugin setup runs under it (see Plugin). */ + owner: Owner | null + /** Plugins whose `setup` has already run in this context (lazy-trigger dedup). */ + initializedPlugins: Set canvas: HTMLCanvasElement clock: Clock camera: CameraKind diff --git a/tests/core/plugins.test.tsx b/tests/core/plugins.test.tsx new file mode 100644 index 00000000..55e61ada --- /dev/null +++ b/tests/core/plugins.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest" +import { Mesh, Object3D } from "three" +import { createT } from "../../src/create-t.tsx" +import { Entity } from "../../src/components.tsx" +import { Pointer, type PointerRaycaster } from "../../src/pointers.ts" +import { test as renderThree } from "../../src/testing/index.tsx" +import { meta } from "../../src/utils.ts" + +const T = createT({ Mesh }) + +describe("core plugin seam", () => { + it("exposes the Canvas reactive owner on context", () => { + const three = renderThree(() => ) + expect(three.owner).toBeTruthy() + three.unmount() + }) + + it("runs a plugin's setup exactly once per context, regardless of element count", () => { + const setup = vi.fn() + const TP = createT({ Mesh }, [{ name: "p", setup }]) + const three = renderThree(() => ( + <> + + + + + )) + expect(setup).toHaveBeenCalledTimes(1) + expect(setup.mock.calls[0]![0].scene).toBe(three.scene) // receives the context + three.unmount() + }) + + it("runs setup once per context across multiple canvases", () => { + const setup = vi.fn() + const TP = createT({ Mesh }, [{ name: "p", setup }]) + const a = renderThree(() => ) + const b = renderThree(() => ) + expect(setup).toHaveBeenCalledTimes(2) // once per ctx, not shared/skipped + a.unmount() + b.unmount() + }) + + it("runs setup for a plugin passed via ", () => { + const setup = vi.fn() + const three = renderThree(() => ) + expect(setup).toHaveBeenCalledTimes(1) + expect(setup.mock.calls[0]![0].scene).toBe(three.scene) + // `plugins` must not leak onto the three instance as a property. + expect("plugins" in three.scene.children[0]!).toBe(false) + three.unmount() + }) + + it("dispatches an arbitrary plugin-named handler, bubbling + canvas-level", () => { + const onXRSelect = vi.fn() + const mesh = meta(new Object3D(), { props: { onXRSelect } }) as any as Object3D + const fakeRaycaster: PointerRaycaster = { + cast: () => [{ object: mesh, distance: 1 } as any], + intersectObject: () => [], + } + const context = { eventRegistry: [mesh], props: {} } as any + const pointer = new Pointer(context, fakeRaycaster) + + pointer.dispatch("onXRSelect", new Event("selectstart")) + expect(onXRSelect).toHaveBeenCalledTimes(1) + }) +}) From 72619decbcd6a67101077daa9c16b84fa6a73889 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 15:55:24 +0200 Subject: [PATCH 02/10] feat(plugins): plugin() with global/class-filter/type-guard forms + type machinery --- src/plugin.ts | 38 ++++++++++++++----- src/types.ts | 69 +++++++++++++++++++++++++++++++--- tests/core/plugins.test.tsx | 75 +++++++++---------------------------- 3 files changed, 109 insertions(+), 73 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 6748acb4..51f399b0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,20 +1,40 @@ import { runWithOwner } from "solid-js" -import type { Context, Plugin } from "./types.ts" +import type { Constructor, Context, Plugin, PluginFn } from "./types.ts" /** - * Run each plugin's `setup` once per context, in the Canvas owner. + * Create a plugin. Three forms: + * - `plugin(el => methods)` — applies to every element. + * - `plugin([Mesh, Camera], el => methods)` — only elements `instanceof` one of the constructors. + * - `plugin(guard, el => methods)` — only elements passing the type-guard. * - * Called from element creation (`createEntity` / ``) — gated by a cheap - * `plugins.length` check there — NOT from the per-attach scene-graph path, so a - * no-plugin app pays nothing. Dedup is per-context (`context.initializedPlugins`), - * so the first element carrying a plugin runs its setup and later ones skip it. - * `runWithOwner(context.owner, …)` ties the setup's lifetime to the Canvas, so its - * `onCleanup` fires on Canvas unmount, not when the triggering element unmounts. + * Returns `(element) => methods | undefined`; a non-matching element yields `undefined`. + * A contributed method's first-param type becomes the element prop type. */ +export const plugin: PluginFn = (selectorOrMethods: any, methods?: any): Plugin => { + if (methods === undefined) { + return (element: any) => selectorOrMethods(element) + } + return (element: any) => { + if (Array.isArray(selectorOrMethods)) { + for (const Ctor of selectorOrMethods as Constructor[]) { + if (element instanceof Ctor) return methods(element) + } + return undefined + } + if (typeof selectorOrMethods === "function" && selectorOrMethods(element)) { + return methods(element) + } + return undefined + } +} + +// TODO(PT8): removed once createT/Entity migrate to resolvePluginMethods (PT4/PT6). +// Kept transitionally so the interim build runs; `Plugin` is now a function type, +// so `.setup` no longer exists — this is a no-op for function-plugins. export function initPlugins(context: Context, plugins: Plugin[]) { for (const plugin of plugins) { if (context.initializedPlugins.has(plugin)) continue context.initializedPlugins.add(plugin) - runWithOwner(context.owner, () => plugin.setup?.(context)) + runWithOwner(context.owner, () => (plugin as any).setup?.(context)) } } diff --git a/src/types.ts b/src/types.ts index 5e685c92..369b4fb6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -230,16 +230,73 @@ export type ResolvedRenderer = Register extends { renderer: infer R } ? R : WebG /* */ /**********************************************************************************/ +/**********************************************************************************/ +/* Plugin */ +/**********************************************************************************/ + +type DistributeOverride = T extends undefined ? F : T +type PluginOverride = T extends any + ? U extends any + ? { + [K in keyof T]: K extends keyof U ? DistributeOverride : T[K] + } & { + [K in keyof U]: K extends keyof T ? DistributeOverride : U[K] + } + : T & U + : T & U +type PluginSimplify = T extends any ? { [K in keyof T]: T[K] } : T +type _PluginMerge = T extends [ + infer Next | (() => infer Next), + ...infer Rest, +] + ? _PluginMerge> + : T extends [...infer Rest, infer Next] + ? PluginOverride<_PluginMerge, Next> + : T extends [] + ? Current + : Current +type PluginMerge = PluginSimplify<_PluginMerge> + /** - * A composable extension. Its `setup` runs once per `Context` (deduped, in the - * Canvas owner) the first time an element carrying it attaches to the scene. - * Input-agnostic — core never inspects what `setup` does. + * A composable extension: a function `(element) => methods`. A contributed + * method's first-param type becomes the element's prop type (see {@link PluginPropsOf}). + * Created via {@link PluginFn} (`plugin()`); a non-matching element yields `undefined`. */ -export type Plugin = { - name?: string - setup?: (context: Context) => void +export type Plugin any> = TFn + +/** The three `plugin()` creation forms: global, class-filtered, type-guard. */ +export interface PluginFn { + >( + methods: (element: any) => Methods, + ): Plugin<(element: any) => Methods> + >( + Constructors: T, + methods: (element: T extends readonly Constructor[] ? U : never) => Methods, + ): Plugin<{ (element: T extends readonly Constructor[] ? U : never): Methods }> + >( + condition: (element: unknown) => element is T, + methods: (element: T) => Methods, + ): Plugin<{ (element: T): Methods }> } +type PluginReturn = TPlugin extends Plugin + ? TFn extends { (element: infer TElement): infer TReturnType } + ? TKind extends TElement + ? TReturnType + : {} + : {} + : {} + +/** Resolves the contributed props for element type `TKind` across `TPlugins`. */ +export type PluginPropsOf = PluginMerge<{ + [K in keyof TPlugins]: PluginReturn extends infer Methods extends Record< + string, + any + > + ? { [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never } + : {} +}> + export interface Context { bounds: Measure /** The Canvas's reactive owner — plugin setup runs under it (see Plugin). */ diff --git a/tests/core/plugins.test.tsx b/tests/core/plugins.test.tsx index 55e61ada..9a7ec6ec 100644 --- a/tests/core/plugins.test.tsx +++ b/tests/core/plugins.test.tsx @@ -1,66 +1,25 @@ import { describe, expect, it, vi } from "vitest" -import { Mesh, Object3D } from "three" -import { createT } from "../../src/create-t.tsx" -import { Entity } from "../../src/components.tsx" -import { Pointer, type PointerRaycaster } from "../../src/pointers.ts" -import { test as renderThree } from "../../src/testing/index.tsx" -import { meta } from "../../src/utils.ts" +import { Mesh, Object3D, PerspectiveCamera } from "three" +import { plugin } from "../../src/plugin.ts" -const T = createT({ Mesh }) - -describe("core plugin seam", () => { - it("exposes the Canvas reactive owner on context", () => { - const three = renderThree(() => ) - expect(three.owner).toBeTruthy() - three.unmount() - }) - - it("runs a plugin's setup exactly once per context, regardless of element count", () => { - const setup = vi.fn() - const TP = createT({ Mesh }, [{ name: "p", setup }]) - const three = renderThree(() => ( - <> - - - - - )) - expect(setup).toHaveBeenCalledTimes(1) - expect(setup.mock.calls[0]![0].scene).toBe(three.scene) // receives the context - three.unmount() +describe("plugin()", () => { + it("global plugin returns methods for any element", () => { + const p = plugin(() => ({ ping: vi.fn() })) + expect(p(new Mesh())).toHaveProperty("ping") }) - it("runs setup once per context across multiple canvases", () => { - const setup = vi.fn() - const TP = createT({ Mesh }, [{ name: "p", setup }]) - const a = renderThree(() => ) - const b = renderThree(() => ) - expect(setup).toHaveBeenCalledTimes(2) // once per ctx, not shared/skipped - a.unmount() - b.unmount() + it("class-filtered plugin returns methods only for matching elements", () => { + const p = plugin([Mesh], () => ({ shake: vi.fn() })) + expect(p(new Mesh())).toHaveProperty("shake") + expect(p(new PerspectiveCamera())).toBeUndefined() }) - it("runs setup for a plugin passed via ", () => { - const setup = vi.fn() - const three = renderThree(() => ) - expect(setup).toHaveBeenCalledTimes(1) - expect(setup.mock.calls[0]![0].scene).toBe(three.scene) - // `plugins` must not leak onto the three instance as a property. - expect("plugins" in three.scene.children[0]!).toBe(false) - three.unmount() - }) - - it("dispatches an arbitrary plugin-named handler, bubbling + canvas-level", () => { - const onXRSelect = vi.fn() - const mesh = meta(new Object3D(), { props: { onXRSelect } }) as any as Object3D - const fakeRaycaster: PointerRaycaster = { - cast: () => [{ object: mesh, distance: 1 } as any], - intersectObject: () => [], - } - const context = { eventRegistry: [mesh], props: {} } as any - const pointer = new Pointer(context, fakeRaycaster) - - pointer.dispatch("onXRSelect", new Event("selectstart")) - expect(onXRSelect).toHaveBeenCalledTimes(1) + it("type-guard plugin returns methods only when the guard passes", () => { + const p = plugin( + (el): el is Mesh => el instanceof Mesh, + () => ({ setColor: vi.fn() }), + ) + expect(p(new Mesh())).toHaveProperty("setColor") + expect(p(new Object3D())).toBeUndefined() }) }) From 81070136a2e095f2b0239f691811d49cb4213a94 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 15:56:26 +0200 Subject: [PATCH 03/10] =?UTF-8?q?feat(plugins):=20resolvePluginMethods=20?= =?UTF-8?q?=E2=80=94=20class-filtered=20plain=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin.ts | 23 +++++++++++++++++++++++ tests/core/plugins.test.tsx | 13 ++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/plugin.ts b/src/plugin.ts index 51f399b0..52abfa98 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -28,6 +28,29 @@ export const plugin: PluginFn = (selectorOrMethods: any, methods?: any): Plugin< } } +/** + * Run each plugin's factory against `element` and merge the returned method + * objects into one plain object (no proxy — optimize for access). Non-matching + * plugins return `undefined` and are skipped. Called once per plugged element at + * creation (gated by `plugins.length`), never on the per-attach path. + */ +export function resolvePluginMethods( + element: object, + plugins: Plugin[], +): Record void> { + const merged: Record = {} + for (const plugin of plugins) { + const result = (plugin as (el: object) => Record | undefined)(element) + if (!result) continue + for (const key in result) { + const descriptor = Object.getOwnPropertyDescriptor(result, key) + if (descriptor?.get || descriptor?.set) Object.defineProperty(merged, key, descriptor) + else merged[key] = result[key] + } + } + return merged +} + // TODO(PT8): removed once createT/Entity migrate to resolvePluginMethods (PT4/PT6). // Kept transitionally so the interim build runs; `Plugin` is now a function type, // so `.setup` no longer exists — this is a no-op for function-plugins. diff --git a/tests/core/plugins.test.tsx b/tests/core/plugins.test.tsx index 9a7ec6ec..1b475cdf 100644 --- a/tests/core/plugins.test.tsx +++ b/tests/core/plugins.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest" import { Mesh, Object3D, PerspectiveCamera } from "three" -import { plugin } from "../../src/plugin.ts" +import { plugin, resolvePluginMethods } from "../../src/plugin.ts" describe("plugin()", () => { it("global plugin returns methods for any element", () => { @@ -23,3 +23,14 @@ describe("plugin()", () => { expect(p(new Object3D())).toBeUndefined() }) }) + +describe("resolvePluginMethods", () => { + it("merges matching plugins' methods, skips non-matching, returns {} for none", () => { + const a = plugin([Mesh], () => ({ shake: () => "shake" })) + const b = plugin(() => ({ ping: () => "ping" })) + const c = plugin([PerspectiveCamera], () => ({ orbit: () => "orbit" })) + const merged = resolvePluginMethods(new Mesh(), [a, b, c]) + expect(Object.keys(merged).sort()).toEqual(["ping", "shake"]) + expect(resolvePluginMethods(new Mesh(), [])).toEqual({}) + }) +}) From 1c76ffccc7f77395b3c7944f34626225e812c9c3 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 15:59:23 +0200 Subject: [PATCH 04/10] feat(plugins): applyProp routes to contributed methods; createT resolves per-element (gated) --- src/create-t.tsx | 9 +++------ src/props.ts | 23 +++++++++++++++++++---- tests/core/plugins.test.tsx | 13 +++++++++++++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/create-t.tsx b/src/create-t.tsx index 49fad5a7..ada815c9 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -1,6 +1,4 @@ import { createMemo, type Component, type JSX } from "solid-js" -import { useThree } from "./hooks.ts" -import { initPlugins } from "./plugin.ts" import { useProps } from "./props.ts" import type { Plugin, Props } from "./types.ts" import { autodispose, meta } from "./utils.ts" @@ -59,10 +57,9 @@ export function createEntity( throw new Error("") } }) - useProps(memo, props) - // Plugin setup is creation-gated, not per-attach: a no-plugin namespace does - // a single closure-length check and never touches the scene-graph hot path. - if (plugins.length) initPlugins(useThree(), plugins) + // Plugin methods are resolved once per element inside useProps, gated by + // plugins.length — a no-plugin namespace never touches the plugin path. + useProps(memo, props, undefined, plugins) return memo as unknown as JSX.Element } } diff --git a/src/props.ts b/src/props.ts index 8f16c4cc..be562734 100644 --- a/src/props.ts +++ b/src/props.ts @@ -22,7 +22,8 @@ import { import { isEventType } from "./create-events.ts" import { useThree } from "./hooks.ts" import { addToEventListeners } from "./internal-context.ts" -import type { AccessorMaybe, Context, Meta } from "./types.ts" +import { resolvePluginMethods } from "./plugin.ts" +import type { AccessorMaybe, Context, Meta, Plugin } from "./types.ts" import { getMeta, hasColorSpace, @@ -214,7 +215,14 @@ function applyProp>( source: T, type: string, value: any, + pluginMethods: Record void>, ) { + // A plugin-contributed prop: invoke its method instead of assigning to the instance. + if (type in pluginMethods) { + pluginMethods[type](value) + return + } + if (!source) { console.error("error while applying prop", source, type, value) return @@ -227,7 +235,7 @@ function applyProp>( if (type.indexOf("-") > -1) { const [property, ...rest] = type.split("-") - applyProp(context, source[property], rest.join("-"), value) + applyProp(context, source[property], rest.join("-"), value, pluginMethods) return } @@ -347,10 +355,13 @@ function applyProp>( * @param props - An object containing the props to apply. This includes both direct properties * and special properties like `ref` and `children`. */ +const EMPTY_METHODS: Record void> = {} + export function useProps>( accessor: T | undefined | Accessor, props: any, context: Pick = useThree(), + plugins: Plugin[] = [], ) { const [local, instanceProps] = splitProps(props, ["ref", "args", "object", "attach", "children"]) @@ -361,6 +372,10 @@ export function useProps>( if (!object) return + // Gated: a no-plugin element does one length check and resolves nothing — + // keeps plugin resolution off the per-element hot path (see plugin-system spec). + const pluginMethods = plugins.length ? resolvePluginMethods(object, plugins) : EMPTY_METHODS + // Assign ref createRenderEffect(() => { if (local.ref instanceof Function) local.ref(object) @@ -375,12 +390,12 @@ export function useProps>( // p.ex in position's subKeys will be ['position-x'] const subKeys = keys.filter(_key => key !== _key && _key.includes(key)) createRenderEffect(() => { - applyProp(context, object, key, props[key]) + applyProp(context, object, key, props[key], pluginMethods) // If property updates, apply its sub-properties immediately after. // NOTE: Discuss - is this expected behavior? Feature or a bug? // Should it be according to order of update instead? for (const subKey of subKeys) { - applyProp(context, object, subKey, props[subKey]) + applyProp(context, object, subKey, props[subKey], pluginMethods) } }) } diff --git a/tests/core/plugins.test.tsx b/tests/core/plugins.test.tsx index 1b475cdf..08b9c543 100644 --- a/tests/core/plugins.test.tsx +++ b/tests/core/plugins.test.tsx @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest" import { Mesh, Object3D, PerspectiveCamera } from "three" import { plugin, resolvePluginMethods } from "../../src/plugin.ts" +import { createT } from "../../src/create-t.tsx" +import { test as renderThree } from "../../src/testing/index.tsx" describe("plugin()", () => { it("global plugin returns methods for any element", () => { @@ -34,3 +36,14 @@ describe("resolvePluginMethods", () => { expect(resolvePluginMethods(new Mesh(), [])).toEqual({}) }) }) + +describe("plugin prop routing", () => { + it("invokes a contributed method when its prop is set, and does not assign it to the instance", () => { + const shake = vi.fn() + const TP = createT({ Mesh }, [plugin([Mesh], () => ({ shake }))]) + const three = renderThree(() => ) + expect(shake).toHaveBeenCalledWith(0.1) + expect("shake" in three.scene.children[0]!).toBe(false) + three.unmount() + }) +}) From 1550e35bac2818ed5afe137769bb51c6357ad264 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 16:11:10 +0200 Subject: [PATCH 05/10] feat(plugins): Props surfaces contributed methods as inferred-typed props createT captures the plugin tuple type and threads it into Props via PluginPropsOf, so a contributed method's first-param type becomes the element prop type. Generics ported from #37 with readonly-tuple constraints; the type-param default is the constraint (readonly Plugin[]) not [] so inference isn't pre-empted. --- src/create-t.tsx | 13 +++++++------ src/types.ts | 21 +++++++++++++-------- tests/core/plugins.test.tsx | 28 ++++++++++++++++++++-------- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/create-t.tsx b/src/create-t.tsx index ada815c9..86d9f385 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -9,13 +9,14 @@ import { autodispose, meta } from "./utils.ts" /* */ /**********************************************************************************/ -export function createT>( - catalogue: TCatalogue, - plugins: Plugin[] = [], -) { +export function createT< + const TCatalogue extends Record, + const TPlugins extends readonly Plugin[] = readonly Plugin[], +>(catalogue: TCatalogue, plugins?: TPlugins) { + const pluginList: Plugin[] = plugins ? [...plugins] : [] const cache = new Map>() return new Proxy<{ - [K in keyof TCatalogue]: Component> + [K in keyof TCatalogue]: Component> }>({} as any, { get: (_, name: string) => { /* Create and memoize a wrapper component for the specified property. */ @@ -27,7 +28,7 @@ export function createT>( if (!constructor) return undefined /* Otherwise, create and memoize a component for that constructor. */ - cache.set(name, createEntity(constructor, plugins)) + cache.set(name, createEntity(constructor, pluginList)) } return cache.get(name) diff --git a/src/types.ts b/src/types.ts index 369b4fb6..5900349e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -245,24 +245,24 @@ type PluginOverride = T extends any : T & U : T & U type PluginSimplify = T extends any ? { [K in keyof T]: T[K] } : T -type _PluginMerge = T extends [ +type _PluginMerge = T extends readonly [ infer Next | (() => infer Next), ...infer Rest, ] ? _PluginMerge> - : T extends [...infer Rest, infer Next] + : T extends readonly [...infer Rest, infer Next] ? PluginOverride<_PluginMerge, Next> - : T extends [] + : T extends readonly [] ? Current : Current -type PluginMerge = PluginSimplify<_PluginMerge> +type PluginMerge = PluginSimplify<_PluginMerge> /** * A composable extension: a function `(element) => methods`. A contributed * method's first-param type becomes the element's prop type (see {@link PluginPropsOf}). * Created via {@link PluginFn} (`plugin()`); a non-matching element yields `undefined`. */ -export type Plugin any> = TFn +export type Plugin any> = TFn /** The three `plugin()` creation forms: global, class-filtered, type-guard. */ export interface PluginFn { @@ -288,7 +288,7 @@ type PluginReturn = TPlugin extends Plugin : {} /** Resolves the contributed props for element type `TKind` across `TPlugins`. */ -export type PluginPropsOf = PluginMerge<{ +export type PluginPropsOf = PluginMerge<{ [K in keyof TPlugins]: PluginReturn extends infer Methods extends Record< string, any @@ -457,8 +457,12 @@ export type MapToRepresentation = { [TKey in keyof T]: Representation } -/** Generic `solid-three` props of a given class. */ -export type Props = Partial< +/** + * Generic `solid-three` props of a given class, optionally widened with the + * props contributed by `TPlugins` for this element class (see {@link PluginPropsOf}). + * `TPlugins` defaults to `[]`, so single-arg `Props` is unchanged. + */ +export type Props = Partial< Overwrite< [ MapToRepresentation>, @@ -476,6 +480,7 @@ export type Props = Partial< */ raycastable: boolean }, + PluginPropsOf, TPlugins>, ] > > diff --git a/tests/core/plugins.test.tsx b/tests/core/plugins.test.tsx index 08b9c543..98faaa06 100644 --- a/tests/core/plugins.test.tsx +++ b/tests/core/plugins.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest" +import { assertType, describe, expect, it, vi } from "vitest" import { Mesh, Object3D, PerspectiveCamera } from "three" import { plugin, resolvePluginMethods } from "../../src/plugin.ts" import { createT } from "../../src/create-t.tsx" @@ -11,18 +11,20 @@ describe("plugin()", () => { }) it("class-filtered plugin returns methods only for matching elements", () => { - const p = plugin([Mesh], () => ({ shake: vi.fn() })) - expect(p(new Mesh())).toHaveProperty("shake") - expect(p(new PerspectiveCamera())).toBeUndefined() + // The filtered overload types the param narrowly; runtime accepts anything, + // so cast to a loose callable to exercise the non-matching path. + const call = plugin([Mesh], () => ({ shake: vi.fn() })) as (el: object) => unknown + expect(call(new Mesh())).toHaveProperty("shake") + expect(call(new PerspectiveCamera())).toBeUndefined() }) it("type-guard plugin returns methods only when the guard passes", () => { - const p = plugin( + const call = plugin( (el): el is Mesh => el instanceof Mesh, () => ({ setColor: vi.fn() }), - ) - expect(p(new Mesh())).toHaveProperty("setColor") - expect(p(new Object3D())).toBeUndefined() + ) as (el: object) => unknown + expect(call(new Mesh())).toHaveProperty("setColor") + expect(call(new Object3D())).toBeUndefined() }) }) @@ -47,3 +49,13 @@ describe("plugin prop routing", () => { three.unmount() }) }) + +describe("plugin prop types", () => { + it("a contributed method's first-param type becomes the element prop type", () => { + const TP = createT({ Mesh }, [plugin([Mesh], () => ({ shake: (_intensity: number) => {} }))]) + type MeshProps = Parameters[0] + // vitest's assertType is tsc-checked (lint:types): errors if `shake` isn't a `number` prop. + assertType(({} as MeshProps).shake) + expect(true).toBe(true) + }) +}) From 5b9c681af316d3a690bc9b0ed8576776c351702d Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 16:53:23 +0200 Subject: [PATCH 06/10] feat(plugins): typed+enforced via UnionToIntersection + readonly tuple Per-element typed contributed props now infer from the JSX plugins prop AND enforce (reject bogus props). Root cause of the prior failure (investigated empirically): - a const-inferred JSX array is a readonly tuple -> mutable Plugin[] constraint rejected it (=> readonly Plugin[] constraints throughout); - the recursive PluginMerge is unevaluable during inference (=> UnionToIntersection); - PluginPropsOf buried in Props's Overwrite defeats inference (=> Props stays plugin-free; PluginPropsOf is intersected directly at createT's proxy and ). createT namespace path keeps working via function-arg inference. --- src/components.tsx | 59 ++++++++++++++++++-------------- src/create-t.tsx | 6 ++-- src/types.ts | 68 ++++++++++++++++--------------------- tests/core/plugins.test.tsx | 22 ++++++++++++ 4 files changed, 89 insertions(+), 66 deletions(-) diff --git a/src/components.tsx b/src/components.tsx index 3854cadf..d6b1ad2a 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -10,9 +10,16 @@ import { } from "solid-js" import { Loader, Object3D } from "three" import { threeContext, useLoader, useThree, type UseLoaderOptions } from "./hooks.ts" -import { initPlugins } from "./plugin.ts" import { useProps } from "./props.ts" -import type { Constructor, LoaderData, LoaderUrl, Meta, Overwrite, Plugin, Props } from "./types.ts" +import type { + Constructor, + LoaderData, + LoaderUrl, + Meta, + Plugin, + PluginPropsOf, + Props, +} from "./types.ts" import { type InstanceOf } from "./types.ts" import { autodispose, hasMeta, isConstructor, meta, withContext, type LoadOutput } from "./utils.ts" @@ -73,43 +80,43 @@ export function Portal(props: PortalProps) { /* */ /**********************************************************************************/ -type EntityProps> = Overwrite< - [ - Props, - { - from: T | undefined - children?: JSXElement - /** Plugins scoped to this element (see Plugin / createT). */ - plugins?: Plugin[] - }, - ] -> /** * Wraps a `ThreeElement` and allows it to be used as a JSX-component within a `solid-three` scene. * - * @function Entity - * @template T - Extends `ThreeInstance` - * @param props - The properties for the Three.js object including the object instance's methods, - * optional children, and a ref that provides access to the object instance. - * @returns The Three.js object wrapped as a JSX element, allowing it to be used within Solid's component system. + * The `{from, children, plugins} & Props` intersection (rather than a + * wrapper) + `const TPlugins` is what lets the JSX `plugins` array const-infer into + * a tuple, so a per-element plugin's contributed methods surface as typed props. + * + * @param props - The Three.js object's props (methods, children, ref) plus the + * element-scoped `plugins` and their contributed props. + * @returns The Three.js object wrapped as a JSX element. */ -export function Entity>(props: EntityProps) { - // `plugins` is read off `childMeta.props` by the scene-graph plugin trigger; - // split it out of `rest` so it isn't applied to the three instance as a property. - const [config, rest] = splitProps(props, ["from", "args", "plugins"]) +export function Entity< + const T extends object | Constructor = object, + const TPlugins extends readonly Plugin[] = readonly Plugin[], +>( + // PluginPropsOf is intersected DIRECTLY (not via Props's Overwrite tuple): burying + // TPlugins inside Overwrite kills its inference at the JSX site. `Props` (default + // plugins) gives the base props; the direct `& Partial>` + // surfaces contributed props with a JSX-inferrable TPlugins. (createT doesn't need + // this — it infers TPlugins from its function arg before Props is instantiated.) + props: { from: T; children?: JSXElement; plugins?: TPlugins } & Props & + Partial, TPlugins>>, +) { + // `plugins` is split out of `rest` so it isn't applied to the three instance; + // its contributed methods are resolved once (gated) inside useProps. + const [config, rest] = splitProps(props as any, ["from", "args", "plugins"]) const instance = createMemo(() => { const from = config.from if (!from) return undefined // track key changes to force reconstruction - props.key + ;(props as any).key return meta( isConstructor(from) ? autodispose(new from(...(config.args ?? []))) : from, { props }, ) as Meta }) - useProps(instance, rest) - // Creation-gated plugin setup (see createEntity): only when this element opts in. - if (config.plugins?.length) initPlugins(useThree(), config.plugins) + useProps(instance, rest, undefined, config.plugins ? [...config.plugins] : []) return instance as unknown as JSX.Element } diff --git a/src/create-t.tsx b/src/create-t.tsx index 86d9f385..d1b25deb 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -1,6 +1,6 @@ import { createMemo, type Component, type JSX } from "solid-js" import { useProps } from "./props.ts" -import type { Plugin, Props } from "./types.ts" +import type { InstanceOf, Plugin, PluginPropsOf, Props } from "./types.ts" import { autodispose, meta } from "./utils.ts" /**********************************************************************************/ @@ -16,7 +16,9 @@ export function createT< const pluginList: Plugin[] = plugins ? [...plugins] : [] const cache = new Map>() return new Proxy<{ - [K in keyof TCatalogue]: Component> + [K in keyof TCatalogue]: Component< + Props & Partial, TPlugins>> + > }>({} as any, { get: (_, name: string) => { /* Create and memoize a wrapper component for the specified property. */ diff --git a/src/types.ts b/src/types.ts index 5900349e..1fdf0ace 100644 --- a/src/types.ts +++ b/src/types.ts @@ -234,35 +234,25 @@ export type ResolvedRenderer = Register extends { renderer: infer R } ? R : WebG /* Plugin */ /**********************************************************************************/ -type DistributeOverride = T extends undefined ? F : T -type PluginOverride = T extends any - ? U extends any - ? { - [K in keyof T]: K extends keyof U ? DistributeOverride : T[K] - } & { - [K in keyof U]: K extends keyof T ? DistributeOverride : U[K] - } - : T & U - : T & U -type PluginSimplify = T extends any ? { [K in keyof T]: T[K] } : T -type _PluginMerge = T extends readonly [ - infer Next | (() => infer Next), - ...infer Rest, -] - ? _PluginMerge> - : T extends readonly [...infer Rest, infer Next] - ? PluginOverride<_PluginMerge, Next> - : T extends readonly [] - ? Current - : Current -type PluginMerge = PluginSimplify<_PluginMerge> +// Intersect a union of method-prop objects into one object. UnionToIntersection +// (rather than a recursive tuple merge) is deliberate: TS can evaluate it *during* +// JSX generic inference, so `` infers +// `TPlugins` from the prop. A recursive merge over the plugin tuple is too heavy to +// evaluate at inference time and silently defaults the type-param (investigated +// empirically — see docs/superpowers/notes). The plugin-tuple constraints are +// `readonly` because a `const`-inferred JSX array is a readonly tuple. +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never /** * A composable extension: a function `(element) => methods`. A contributed * method's first-param type becomes the element's prop type (see {@link PluginPropsOf}). * Created via {@link PluginFn} (`plugin()`); a non-matching element yields `undefined`. */ -export type Plugin any> = TFn +export type Plugin any> = TFn /** The three `plugin()` creation forms: global, class-filtered, type-guard. */ export interface PluginFn { @@ -272,11 +262,11 @@ export interface PluginFn { >( Constructors: T, methods: (element: T extends readonly Constructor[] ? U : never) => Methods, - ): Plugin<{ (element: T extends readonly Constructor[] ? U : never): Methods }> + ): Plugin<(element: T extends readonly Constructor[] ? U : never) => Methods> >( condition: (element: unknown) => element is T, methods: (element: T) => Methods, - ): Plugin<{ (element: T): Methods }> + ): Plugin<(element: T) => Methods> } type PluginReturn = TPlugin extends Plugin @@ -288,14 +278,16 @@ type PluginReturn = TPlugin extends Plugin : {} /** Resolves the contributed props for element type `TKind` across `TPlugins`. */ -export type PluginPropsOf = PluginMerge<{ - [K in keyof TPlugins]: PluginReturn extends infer Methods extends Record< - string, - any - > - ? { [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never } - : {} -}> +export type PluginPropsOf = UnionToIntersection< + { + [K in keyof TPlugins]: PluginReturn extends infer Methods extends Record< + string, + any + > + ? { [M in keyof Methods]: Methods[M] extends (value: infer V) => any ? V : never } + : {} + }[number] +> export interface Context { bounds: Measure @@ -458,11 +450,12 @@ export type MapToRepresentation = { } /** - * Generic `solid-three` props of a given class, optionally widened with the - * props contributed by `TPlugins` for this element class (see {@link PluginPropsOf}). - * `TPlugins` defaults to `[]`, so single-arg `Props` is unchanged. + * Generic `solid-three` props of a given class. Plugin-contributed props are NOT + * baked in here — they're intersected directly at the composition sites (`createT` + * proxy + ``) via {@link PluginPropsOf}, which keeps `TPlugins` inferable at + * those sites (burying it in this `Overwrite` defeats inference — see notes). */ -export type Props = Partial< +export type Props = Partial< Overwrite< [ MapToRepresentation>, @@ -480,7 +473,6 @@ export type Props = Partial< */ raycastable: boolean }, - PluginPropsOf, TPlugins>, ] > > diff --git a/tests/core/plugins.test.tsx b/tests/core/plugins.test.tsx index 98faaa06..3026e62e 100644 --- a/tests/core/plugins.test.tsx +++ b/tests/core/plugins.test.tsx @@ -2,6 +2,7 @@ import { assertType, describe, expect, it, vi } from "vitest" import { Mesh, Object3D, PerspectiveCamera } from "three" import { plugin, resolvePluginMethods } from "../../src/plugin.ts" import { createT } from "../../src/create-t.tsx" +import { Entity } from "../../src/components.tsx" import { test as renderThree } from "../../src/testing/index.tsx" describe("plugin()", () => { @@ -50,6 +51,27 @@ describe("plugin prop routing", () => { }) }) +describe("", () => { + it("invokes a contributed method passed via ", () => { + const shake = vi.fn() + const three = renderThree(() => ( + ({ shake }))]} shake={0.2} /> + )) + expect(shake).toHaveBeenCalledWith(0.2) + expect("shake" in three.scene.children[0]!).toBe(false) + three.unmount() + }) + + it("enforces contributed-prop types (rejects a bogus prop) — not permissive like #37", () => { + const p = plugin([Mesh], () => ({ shake: (_i: number) => {} })) + const three = renderThree(() => ( + // @ts-expect-error — `boguz` is not a contributed prop; lint:types must reject it. + + )) + three.unmount() + }) +}) + describe("plugin prop types", () => { it("a contributed method's first-param type becomes the element prop type", () => { const TP = createT({ Mesh }, [plugin([Mesh], () => ({ shake: (_intensity: number) => {} }))]) From 79d26140a6a65ada9a5b98fc3c8b05f9896de5a4 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 17:01:46 +0200 Subject: [PATCH 07/10] refactor(plugins): PropsWithPlugins helper (base + contributed props) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRYs the 'Props & Partial, TPlugins>>' intersection used by createT's element proxy and . A plain top-level intersection alias — keeps PluginPropsOf un-buried so TPlugins stays inferable; verified inference + enforcement unchanged (lint:types clean, @ts-expect-error bogus-prop still holds). --- src/components.tsx | 18 +++++++----------- src/create-t.tsx | 6 ++---- src/types.ts | 10 ++++++++++ 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/components.tsx b/src/components.tsx index d6b1ad2a..111bb926 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -17,8 +17,8 @@ import type { LoaderUrl, Meta, Plugin, - PluginPropsOf, Props, + PropsWithPlugins, } from "./types.ts" import { type InstanceOf } from "./types.ts" import { autodispose, hasMeta, isConstructor, meta, withContext, type LoadOutput } from "./utils.ts" @@ -83,9 +83,9 @@ export function Portal(props: PortalProps) { /** * Wraps a `ThreeElement` and allows it to be used as a JSX-component within a `solid-three` scene. * - * The `{from, children, plugins} & Props` intersection (rather than a - * wrapper) + `const TPlugins` is what lets the JSX `plugins` array const-infer into - * a tuple, so a per-element plugin's contributed methods surface as typed props. + * The `& PropsWithPlugins` intersection + `const TPlugins` is what lets + * the JSX `plugins` array const-infer into a tuple, so a per-element plugin's + * contributed methods surface as typed (and enforced) props. * * @param props - The Three.js object's props (methods, children, ref) plus the * element-scoped `plugins` and their contributed props. @@ -95,13 +95,9 @@ export function Entity< const T extends object | Constructor = object, const TPlugins extends readonly Plugin[] = readonly Plugin[], >( - // PluginPropsOf is intersected DIRECTLY (not via Props's Overwrite tuple): burying - // TPlugins inside Overwrite kills its inference at the JSX site. `Props` (default - // plugins) gives the base props; the direct `& Partial>` - // surfaces contributed props with a JSX-inferrable TPlugins. (createT doesn't need - // this — it infers TPlugins from its function arg before Props is instantiated.) - props: { from: T; children?: JSXElement; plugins?: TPlugins } & Props & - Partial, TPlugins>>, + // PropsWithPlugins keeps PluginPropsOf a direct top-level intersection (not buried in + // Props's Overwrite) so TPlugins stays inferable from the JSX `plugins` prop. + props: { from: T; children?: JSXElement; plugins?: TPlugins } & PropsWithPlugins, ) { // `plugins` is split out of `rest` so it isn't applied to the three instance; // its contributed methods are resolved once (gated) inside useProps. diff --git a/src/create-t.tsx b/src/create-t.tsx index d1b25deb..44d9112e 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -1,6 +1,6 @@ import { createMemo, type Component, type JSX } from "solid-js" import { useProps } from "./props.ts" -import type { InstanceOf, Plugin, PluginPropsOf, Props } from "./types.ts" +import type { Plugin, Props, PropsWithPlugins } from "./types.ts" import { autodispose, meta } from "./utils.ts" /**********************************************************************************/ @@ -16,9 +16,7 @@ export function createT< const pluginList: Plugin[] = plugins ? [...plugins] : [] const cache = new Map>() return new Proxy<{ - [K in keyof TCatalogue]: Component< - Props & Partial, TPlugins>> - > + [K in keyof TCatalogue]: Component> }>({} as any, { get: (_, name: string) => { /* Create and memoize a wrapper component for the specified property. */ diff --git a/src/types.ts b/src/types.ts index 1fdf0ace..d2fe3c12 100644 --- a/src/types.ts +++ b/src/types.ts @@ -277,6 +277,16 @@ type PluginReturn = TPlugin extends Plugin : {} : {} +/** + * An element's full prop type: its base {@link Props} plus the props contributed by + * `TPlugins` for this element class. `PluginPropsOf` is intersected DIRECTLY (a plain + * top-level intersection, not nested in `Props`'s `Overwrite`) so `TPlugins` stays + * inferable at the JSX/usage site — see the inference notes. Used by `createT`'s + * element proxy and ``. + */ +export type PropsWithPlugins = Props & + Partial, TPlugins>> + /** Resolves the contributed props for element type `TKind` across `TPlugins`. */ export type PluginPropsOf = UnionToIntersection< { From 5a4f0224c1e831a54fdc30ace272853923d8da54 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 17:04:46 +0200 Subject: [PATCH 08/10] refactor(plugins): rename Props->BaseProps, PropsWithPlugins->Props BaseProps = raw three-instance props; Props = base + plugin-contributed props. lint:types clean, full suite + plugin inference/enforcement tests green. --- src/canvas.tsx | 19 ++++--- src/components.tsx | 26 ++++------ src/create-t.tsx | 8 +-- src/types.ts | 126 ++++++++++++++++++++++----------------------- 4 files changed, 86 insertions(+), 93 deletions(-) diff --git a/src/canvas.tsx b/src/canvas.tsx index 88a3b098..2ffc8357 100644 --- a/src/canvas.tsx +++ b/src/canvas.tsx @@ -11,7 +11,13 @@ import { } from "three" import { createThree } from "./create-three.tsx" import type { EventRaycaster } from "./raycasters.tsx" -import type { CanvasEventHandlers, Context, Props, RefWithCleanup, ResolvedRenderer } from "./types.ts" +import type { + BaseProps, + CanvasEventHandlers, + Context, + RefWithCleanup, + ResolvedRenderer, +} from "./types.ts" /** * Props for the Canvas component, which initializes the Three.js rendering context and acts as the root for your 3D scene. @@ -20,9 +26,9 @@ export interface CanvasProps extends ParentProps> { ref?: RefWithCleanup class?: string /** Configuration for the camera used in the scene. */ - camera?: Partial | Props> | Camera + camera?: Partial | BaseProps> | Camera /** Configuration for the Raycaster used for mouse and pointer events. */ - raycaster?: Partial> | EventRaycaster | Raycaster + raycaster?: Partial> | EventRaycaster | Raycaster /** Element to render while the main content is loading asynchronously. */ fallback?: JSX.Element /** Toggles flat interpolation for texture filtering. */ @@ -43,8 +49,7 @@ export interface CanvasProps extends ParentProps> { * The accepted renderer type narrows when you declare it via the `Register` * module-augmentation interface — see {@link Register} in `types.ts`. */ - gl?: - // Flat object accepts both `WebGLRendererParameters` (constructor-only, + gl?: // Flat object accepts both `WebGLRendererParameters` (constructor-only, // e.g. `antialias`, `alpha`) and writable instance props (e.g. // `toneMapping`). solid-three splits them at construction: constructor args // are baked once; instance props stay reactive. Inspired by r3f's `gl` prop. @@ -52,7 +57,7 @@ export interface CanvasProps extends ParentProps> { // collapses to `never` so the user is forced into the factory or instance // form that matches their declared renderer. | (WebGLRenderer extends ResolvedRenderer - ? Partial & WebGLRendererParameters> + ? Partial & WebGLRendererParameters> : never) | ((canvas: HTMLCanvasElement) => ResolvedRenderer) | ResolvedRenderer @@ -61,7 +66,7 @@ export interface CanvasProps extends ParentProps> { /** Toggles between Orthographic and Perspective camera. */ orthographic?: boolean /** Configuration for the Scene instance. */ - scene?: Partial> | Scene + scene?: Partial> | Scene /** Enables and configures shadows in the scene. */ shadows?: boolean | "basic" | "percentage" | "soft" | "variance" | WebGLRenderer["shadowMap"] /** Custom CSS styles for the canvas container. */ diff --git a/src/components.tsx b/src/components.tsx index 111bb926..bcb075e5 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -11,15 +11,7 @@ import { import { Loader, Object3D } from "three" import { threeContext, useLoader, useThree, type UseLoaderOptions } from "./hooks.ts" import { useProps } from "./props.ts" -import type { - Constructor, - LoaderData, - LoaderUrl, - Meta, - Plugin, - Props, - PropsWithPlugins, -} from "./types.ts" +import type { BaseProps, Constructor, LoaderData, LoaderUrl, Meta, Plugin, Props } from "./types.ts" import { type InstanceOf } from "./types.ts" import { autodispose, hasMeta, isConstructor, meta, withContext, type LoadOutput } from "./utils.ts" @@ -97,20 +89,20 @@ export function Entity< >( // PropsWithPlugins keeps PluginPropsOf a direct top-level intersection (not buried in // Props's Overwrite) so TPlugins stays inferable from the JSX `plugins` prop. - props: { from: T; children?: JSXElement; plugins?: TPlugins } & PropsWithPlugins, + props: { from: T; children?: JSXElement; plugins?: TPlugins } & Props, ) { // `plugins` is split out of `rest` so it isn't applied to the three instance; // its contributed methods are resolved once (gated) inside useProps. const [config, rest] = splitProps(props as any, ["from", "args", "plugins"]) const instance = createMemo(() => { const from = config.from - if (!from) return undefined - // track key changes to force reconstruction + if (!from) + return undefined + // track key changes to force reconstruction ;(props as any).key - return meta( - isConstructor(from) ? autodispose(new from(...(config.args ?? []))) : from, - { props }, - ) as Meta + return meta(isConstructor(from) ? autodispose(new from(...(config.args ?? []))) : from, { + props, + }) as Meta }) useProps(instance, rest, undefined, config.plugins ? [...config.plugins] : []) return instance as unknown as JSX.Element @@ -126,7 +118,7 @@ type ResourceProps> = UseLoaderOptions< TLoader, LoaderUrl > & - Omit>, "children"> & { + Omit>, "children"> & { loader: Constructor url: LoaderUrl children?: (result: Accessor>>) => JSXElement diff --git a/src/create-t.tsx b/src/create-t.tsx index 44d9112e..5b79b7ed 100644 --- a/src/create-t.tsx +++ b/src/create-t.tsx @@ -1,6 +1,6 @@ import { createMemo, type Component, type JSX } from "solid-js" import { useProps } from "./props.ts" -import type { Plugin, Props, PropsWithPlugins } from "./types.ts" +import type { BaseProps, Plugin, Props } from "./types.ts" import { autodispose, meta } from "./utils.ts" /**********************************************************************************/ @@ -16,7 +16,7 @@ export function createT< const pluginList: Plugin[] = plugins ? [...plugins] : [] const cache = new Map>() return new Proxy<{ - [K in keyof TCatalogue]: Component> + [K in keyof TCatalogue]: Component> }>({} as any, { get: (_, name: string) => { /* Create and memoize a wrapper component for the specified property. */ @@ -46,8 +46,8 @@ export function createT< export function createEntity( Constructor: TConstructor, plugins: Plugin[] = [], -): Component> { - return (props: Props) => { +): Component> { + return (props: BaseProps) => { const memo = createMemo(() => { // listen to key changes props.key diff --git a/src/types.ts b/src/types.ts index d2fe3c12..f7056a1f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,8 +54,8 @@ export type Overwrite = T extends [infer First, ...infer Re ? Rest extends [] ? First : Overwrite extends infer Result - ? Omit & Result - : never + ? Omit & Result + : never : never /** Intersect a tuple of types: `Intersect<[A, B, C]>` → `A & B & C`. */ @@ -95,53 +95,50 @@ export type ConstructorOverloadParameters = T extends { } ? U | U2 | U3 | U4 | U5 | U6 | U7 : T extends { - new (...o: infer U): void - new (...o: infer U2): void - new (...o: infer U3): void - new (...o: infer U4): void - new (...o: infer U5): void - new (...o: infer U6): void - } - ? U | U2 | U3 | U4 | U5 | U6 - : T extends { - new (...o: infer U): void - new (...o: infer U2): void - new (...o: infer U3): void - new (...o: infer U4): void - new (...o: infer U5): void - } - ? U | U2 | U3 | U4 | U5 - : T extends { - new (...o: infer U): void - new (...o: infer U2): void - new (...o: infer U3): void - new (...o: infer U4): void - } - ? U | U2 | U3 | U4 - : T extends { - new (...o: infer U): void - new (...o: infer U2): void - new (...o: infer U3): void - } - ? U | U2 | U3 - : T extends { - new (...o: infer U): void - new (...o: infer U2): void - } - ? U | U2 - : T extends { - new (...o: infer U): void - } - ? U - : never - -export type LoaderData> = T extends Loader - ? TData - : never - -export type LoaderUrl> = T extends Loader - ? TUrl - : never + new (...o: infer U): void + new (...o: infer U2): void + new (...o: infer U3): void + new (...o: infer U4): void + new (...o: infer U5): void + new (...o: infer U6): void + } + ? U | U2 | U3 | U4 | U5 | U6 + : T extends { + new (...o: infer U): void + new (...o: infer U2): void + new (...o: infer U3): void + new (...o: infer U4): void + new (...o: infer U5): void + } + ? U | U2 | U3 | U4 | U5 + : T extends { + new (...o: infer U): void + new (...o: infer U2): void + new (...o: infer U3): void + new (...o: infer U4): void + } + ? U | U2 | U3 | U4 + : T extends { + new (...o: infer U): void + new (...o: infer U2): void + new (...o: infer U3): void + } + ? U | U2 | U3 + : T extends { + new (...o: infer U): void + new (...o: infer U2): void + } + ? U | U2 + : T extends { + new (...o: infer U): void + } + ? U + : never + +export type LoaderData> = + T extends Loader ? TData : never + +export type LoaderUrl> = T extends Loader ? TUrl : never /**********************************************************************************/ /* */ @@ -241,9 +238,7 @@ export type ResolvedRenderer = Register extends { renderer: infer R } ? R : WebG // evaluate at inference time and silently defaults the type-param (investigated // empirically — see docs/superpowers/notes). The plugin-tuple constraints are // `readonly` because a `const`-inferred JSX array is a readonly tuple. -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( - k: infer I, -) => void +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never @@ -269,22 +264,23 @@ export interface PluginFn { ): Plugin<(element: T) => Methods> } -type PluginReturn = TPlugin extends Plugin - ? TFn extends { (element: infer TElement): infer TReturnType } - ? TKind extends TElement - ? TReturnType +type PluginReturn = + TPlugin extends Plugin + ? TFn extends { (element: infer TElement): infer TReturnType } + ? TKind extends TElement + ? TReturnType + : {} : {} : {} - : {} /** - * An element's full prop type: its base {@link Props} plus the props contributed by + * An element's full prop type: its base {@link BaseProps} plus the props contributed by * `TPlugins` for this element class. `PluginPropsOf` is intersected DIRECTLY (a plain * top-level intersection, not nested in `Props`'s `Overwrite`) so `TPlugins` stays * inferable at the JSX/usage site — see the inference notes. Used by `createT`'s * element proxy and ``. */ -export type PropsWithPlugins = Props & +export type Props = BaseProps & Partial, TPlugins>> /** Resolves the contributed props for element type `TKind` across `TPlugins`. */ @@ -422,10 +418,10 @@ interface ThreeVectorRepresentation extends ThreeMathRepresentation { export type Representation = T extends ThreeColor ? ConstructorParameters | ColorRepresentation : T extends ThreeVectorRepresentation | ThreeLayers | ThreeEuler - ? T | Parameters | number - : T extends ThreeMathRepresentation - ? T | Parameters - : T + ? T | Parameters | number + : T extends ThreeMathRepresentation + ? T | Parameters + : T export type Vector2 = Representation export type Vector3 = Representation @@ -449,7 +445,7 @@ export type Meta = T & { /** Metadata of a `solid-three` instance. */ export type Data = { - props: Props> + props: BaseProps> parent: any children: Set> } @@ -465,7 +461,7 @@ export type MapToRepresentation = { * proxy + ``) via {@link PluginPropsOf}, which keeps `TPlugins` inferable at * those sites (burying it in this `Overwrite` defeats inference — see notes). */ -export type Props = Partial< +export type BaseProps = Partial< Overwrite< [ MapToRepresentation>, From fe7246a988d8b5da909e7b6c004128d63a392327 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 17:12:14 +0200 Subject: [PATCH 09/10] feat(plugins): Context.initializePlugin + meta.ctx (mount-site context for plugin code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Data.ctx: the element's mount-site Context, set by useProps for plugged elements (at creation, not attach — contributed methods run during applyProp, before a top-level element attaches). Gated by plugins.length so no-plugin elements pay nothing. - Context.initializePlugin(token, fn): runs fn once per context (private dedup set), the home for a plugin's one-time per-context setup. Replaces the Phase-1 initializedPlugins field; removes the dead initPlugins (setup-object remnant). Plugin code reaches the store via getMeta(element).ctx. --- src/create-three.tsx | 9 ++++++++- src/plugin.ts | 14 +------------- src/props.ts | 10 +++++++++- src/types.ts | 14 ++++++++++++-- tests/core/plugins.test.tsx | 24 ++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/create-three.tsx b/src/create-three.tsx index 37f304b3..8d8a6ad3 100644 --- a/src/create-three.tsx +++ b/src/create-three.tsx @@ -352,12 +352,19 @@ export function createThree(canvas: HTMLCanvasElement, props: CanvasProps) { const clock = new Clock() clock.start() + // Per-context dedup set for `initializePlugin` (private — exposed only via the method). + const initializedPlugins = new Set() + const context: Context = { get bounds() { return measure.bounds() }, owner: getOwner(), - initializedPlugins: new Set(), + initializePlugin(token: unknown, fn: () => void) { + if (initializedPlugins.has(token)) return + initializedPlugins.add(token) + fn() + }, canvas, clock, eventRegistry: [], diff --git a/src/plugin.ts b/src/plugin.ts index 52abfa98..da46a21c 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,5 +1,4 @@ -import { runWithOwner } from "solid-js" -import type { Constructor, Context, Plugin, PluginFn } from "./types.ts" +import type { Constructor, Plugin, PluginFn } from "./types.ts" /** * Create a plugin. Three forms: @@ -50,14 +49,3 @@ export function resolvePluginMethods( } return merged } - -// TODO(PT8): removed once createT/Entity migrate to resolvePluginMethods (PT4/PT6). -// Kept transitionally so the interim build runs; `Plugin` is now a function type, -// so `.setup` no longer exists — this is a no-op for function-plugins. -export function initPlugins(context: Context, plugins: Plugin[]) { - for (const plugin of plugins) { - if (context.initializedPlugins.has(plugin)) continue - context.initializedPlugins.add(plugin) - runWithOwner(context.owner, () => (plugin as any).setup?.(context)) - } -} diff --git a/src/props.ts b/src/props.ts index be562734..66903562 100644 --- a/src/props.ts +++ b/src/props.ts @@ -374,7 +374,15 @@ export function useProps>( // Gated: a no-plugin element does one length check and resolves nothing — // keeps plugin resolution off the per-element hot path (see plugin-system spec). - const pluginMethods = plugins.length ? resolvePluginMethods(object, plugins) : EMPTY_METHODS + let pluginMethods = EMPTY_METHODS + if (plugins.length) { + pluginMethods = resolvePluginMethods(object, plugins) + // Give plugin code the mount-site context via getMeta(element).ctx. Set at + // creation (here), not attach: contributed methods run during applyProp, before + // a top-level element attaches to the scene. Gated, so no-plugin elements pay nothing. + const childMeta = getMeta(object) + if (childMeta) childMeta.ctx = context as Context + } // Assign ref createRenderEffect(() => { diff --git a/src/types.ts b/src/types.ts index f7056a1f..debabce7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -299,8 +299,12 @@ export interface Context { bounds: Measure /** The Canvas's reactive owner — plugin setup runs under it (see Plugin). */ owner: Owner | null - /** Plugins whose `setup` has already run in this context (lazy-trigger dedup). */ - initializedPlugins: Set + /** + * Run `fn` exactly once per context for a given `token` (a plugin or a symbol the + * author chooses). The home for a plugin's one-time, per-context setup — e.g. an + * XR plugin wiring its controller source on the first `onXRSelect` registration. + */ + initializePlugin(token: unknown, fn: () => void): void canvas: HTMLCanvasElement clock: Clock camera: CameraKind @@ -448,6 +452,12 @@ export type Data = { props: BaseProps> parent: any children: Set> + /** + * The context this element is rendered under (its mount-site `Context`), set by + * `useProps` for plugged elements. Plugin methods reach the store via + * `getMeta(element).ctx` — see {@link Plugin} / {@link Context.initializePlugin}. + */ + ctx?: Context } /** Maps properties of given type to their `solid-three` representations. */ diff --git a/tests/core/plugins.test.tsx b/tests/core/plugins.test.tsx index 3026e62e..3e118965 100644 --- a/tests/core/plugins.test.tsx +++ b/tests/core/plugins.test.tsx @@ -4,6 +4,7 @@ import { plugin, resolvePluginMethods } from "../../src/plugin.ts" import { createT } from "../../src/create-t.tsx" import { Entity } from "../../src/components.tsx" import { test as renderThree } from "../../src/testing/index.tsx" +import { getMeta } from "../../src/utils.ts" describe("plugin()", () => { it("global plugin returns methods for any element", () => { @@ -81,3 +82,26 @@ describe("plugin prop types", () => { expect(true).toBe(true) }) }) + +describe("meta.ctx + initializePlugin", () => { + it("a contributed method reaches the mount-site context via getMeta(el).ctx and dedups once-per-ctx", () => { + const setupOnce = vi.fn() + const token = Symbol("test") + const p = plugin(el => ({ + onPing() { + const ctx = getMeta(el)!.ctx! + ctx.initializePlugin(token, setupOnce) + }, + })) + const TP = createT({ Mesh }, [p]) + const three = renderThree(() => ( + <> + {}} /> + {}} /> + + )) + expect(setupOnce).toHaveBeenCalledTimes(1) // once per ctx across both meshes + expect(getMeta(three.scene.children[0]!)?.ctx?.scene).toBe(three.scene) + three.unmount() + }) +}) From 318abb5e116fa7872c5d4cad0372934a5c7b84e1 Mon Sep 17 00:00:00 2001 From: bigmistqke Date: Thu, 4 Jun 2026 17:13:35 +0200 Subject: [PATCH 10/10] feat(plugins): export plugin() from package entry No Phase-1 remnants remain (initPlugins/setup-object removed in PT7). plugin() is the public creator; Plugin type already exported; advanced Props types via the S3 namespace. --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 082053e3..da64ebb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ 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 { Pointer, createThreeEvent, type PointerRaycaster } from "./pointers.ts" export { useProps } from "./props.ts" export * from "./raycasters.tsx"