diff --git a/packages/fiber/src/core/events.ts b/packages/fiber/src/core/events.ts index 749d17ca65..dd2c07ab0f 100644 --- a/packages/fiber/src/core/events.ts +++ b/packages/fiber/src/core/events.ts @@ -173,7 +173,7 @@ export function createEvents(store: UseBoundStore) { function filterPointerEvents(objects: THREE.Object3D[]) { return objects.filter((obj) => ['Move', 'Over', 'Enter', 'Out', 'Leave'].some( - (name) => (obj as unknown as Instance).__r3f?.handlers[('onPointer' + name) as keyof EventHandlers], + (name) => ((obj as any).__r3f as Instance)?.handlers[('onPointer' + name) as keyof EventHandlers], ), ) } @@ -239,7 +239,7 @@ export function createEvents(store: UseBoundStore) { let eventObject: THREE.Object3D | null = hit.object // Bubble event up while (eventObject) { - if ((eventObject as unknown as Instance).__r3f?.eventCount) intersections.push({ ...hit, eventObject }) + if (((eventObject as any).__r3f as Instance)?.eventCount) intersections.push({ ...hit, eventObject }) eventObject = eventObject.parent } } @@ -369,7 +369,7 @@ export function createEvents(store: UseBoundStore) { ) ) { const eventObject = hoveredObj.eventObject - const instance = (eventObject as unknown as Instance).__r3f + const instance = (eventObject as any).__r3f as Instance const handlers = instance?.handlers internal.hovered.delete(makeId(hoveredObj)) if (instance?.eventCount) { @@ -434,7 +434,7 @@ export function createEvents(store: UseBoundStore) { handleIntersects(hits, event, delta, (data: ThreeEvent) => { const eventObject = data.eventObject - const instance = (eventObject as unknown as Instance).__r3f + const instance = (eventObject as any).__r3f as Instance const handlers = instance?.handlers // Check presence of handlers if (!instance?.eventCount) return @@ -487,9 +487,7 @@ export function createEvents(store: UseBoundStore) { } function pointerMissed(event: MouseEvent, objects: THREE.Object3D[]) { - objects.forEach((object: THREE.Object3D) => - (object as unknown as Instance).__r3f?.handlers.onPointerMissed?.(event), - ) + objects.forEach((object: THREE.Object3D) => ((object as any).__r3f as Instance)?.handlers.onPointerMissed?.(event)) } return { handlePointer } diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index 0e0605b6dc..9d67d63e3a 100644 --- a/packages/fiber/src/core/index.tsx +++ b/packages/fiber/src/core/index.tsx @@ -32,7 +32,7 @@ import { Camera, updateCamera, } from './utils' -import { useStore } from './hooks' +import { useStore, useThree } from './hooks' import { Stage, Lifecycle, Stages } from './stages' import { OffscreenCanvas } from 'three' @@ -382,6 +382,11 @@ function render( return root.render(children) } +function Scene({ children }: { children: React.ReactNode }) { + const scene = useThree((state) => state.scene) + return {children} +} + function Provider({ store, children, @@ -405,7 +410,11 @@ function Provider({ if (!store.getState().events.connected) state.events.connect?.(rootElement) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - return {children} + return ( + + {children} + + ) } function unmountComponentAtNode(canvas: TElement, callback?: (canvas: TElement) => void) { @@ -556,7 +565,9 @@ function Portal({ return ( <> {reconciler.createPortal( - {children}, + + {children} + , usePortalStore, null, )} diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index ce0bafeb17..8440e68eb1 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -3,39 +3,38 @@ import { UseBoundStore } from 'zustand' import Reconciler from 'react-reconciler' import { unstable_IdlePriority as idlePriority, unstable_scheduleCallback as scheduleCallback } from 'scheduler' import { DefaultEventPriority } from 'react-reconciler/constants' -import { - is, - prepare, - diffProps, - DiffSet, - applyProps, - updateInstance, - invalidateInstance, - attach, - detach, -} from './utils' +import { is, diffProps, applyProps, invalidateInstance, attach, detach } from './utils' import { RootState } from './store' import { EventHandlers, removeInteractivity } from './events' export type Root = { fiber: Reconciler.FiberRoot; store: UseBoundStore } -export type LocalState = { - type: string +export type AttachFnType = (parent: any, self: any) => () => void +export type AttachType = string | AttachFnType + +export type InstanceProps = { + [key: string]: unknown +} & { + args?: any[] + object?: any + visible?: boolean + dispose?: null + attach?: AttachType +} + +export interface Instance { root: UseBoundStore - // objects and parent are used when children are added with `attach` instead of being added to the Object3D scene graph - objects: Instance[] + type: string parent: Instance | null - primitive?: boolean + children: Instance[] + props: InstanceProps + object: any | null eventCount: number handlers: Partial attach?: AttachType - previousAttach: any - memoizedProps: { [key: string]: any } + previousAttach?: any } -export type AttachFnType = (parent: Instance, self: Instance) => () => void -export type AttachType = string | AttachFnType - interface HostConfig { type: string props: InstanceProps @@ -43,240 +42,219 @@ interface HostConfig { instance: Instance textInstance: void suspenseInstance: Instance - hydratableInstance: Instance - publicInstance: Instance + hydratableInstance: never + publicInstance: Instance['object'] hostContext: never - updatePayload: Array + updatePayload: null | [true] | [false, InstanceProps] childSet: never timeoutHandle: number | undefined noTimeout: -1 } -// This type clamps down on a couple of assumptions that we can make regarding native types, which -// could anything from scene objects, THREE.Objects, JSM, user-defined classes and non-scene objects. -// What they all need to have in common is defined here ... -export type BaseInstance = Omit & { - __r3f: LocalState - children: Instance[] - remove: (...object: Instance[]) => Instance - add: (...object: Instance[]) => Instance - raycast?: (raycaster: THREE.Raycaster, intersects: THREE.Intersection[]) => void -} -export type Instance = BaseInstance & { [key: string]: any } - -export type InstanceProps = { - [key: string]: unknown -} & { - args?: any[] - object?: object - visible?: boolean - dispose?: null - attach?: AttachType -} - interface Catalogue { [name: string]: { - new (...args: any): Instance + new (...args: any): any } } -let catalogue: Catalogue = {} -let extend = (objects: object): void => void (catalogue = { ...catalogue, ...objects }) +const catalogue: Catalogue = {} +const extend = (objects: object): void => void Object.assign(catalogue, objects) function createRenderer(_roots: Map, _getEventPriority?: () => any) { function createInstance( type: string, - { args = [], attach, ...props }: InstanceProps, + { args = [], object = null, ...props }: InstanceProps, root: UseBoundStore, - ) { - let name = `${type[0].toUpperCase()}${type.slice(1)}` - let instance: Instance - - if (type === 'primitive') { - if (props.object === undefined) throw new Error("R3F: Primitives without 'object' are invalid!") - const object = props.object as Instance - instance = prepare(object, { type, root, attach, primitive: true }) - } else { - const target = catalogue[name] - if (!target) { - throw new Error( - `R3F: ${name} is not part of the THREE namespace! Did you forget to extend? See: https://docs.pmnd.rs/react-three-fiber/api/objects#using-3rd-party-objects-declaratively`, - ) - } - - // Throw if an object or literal was passed for args - if (!Array.isArray(args)) throw new Error('R3F: The args prop must be an array!') - - // Instanciate new object, link it to the root - // Append memoized props with args so it's not forgotten - instance = prepare(new target(...args), { - type, - root, - attach, - // Save args in case we need to reconstruct later for HMR - memoizedProps: { args }, - }) + ): HostConfig['instance'] { + const name = `${type[0].toUpperCase()}${type.slice(1)}` + const target = catalogue[name] + + if (type !== 'primitive' && !target) + throw new Error( + `R3F: ${name} is not part of the THREE namespace! Did you forget to extend? See: https://docs.pmnd.rs/react-three-fiber/api/objects#using-3rd-party-objects-declaratively`, + ) + + if (type === 'primitive' && !object) throw new Error(`R3F: Primitives without 'object' are invalid!`) + + const instance: HostConfig['instance'] = { + root, + type, + parent: null, + children: [], + props: { ...props, args }, + object, + eventCount: 0, + handlers: {}, } - // Auto-attach geometries and materials - if (instance.__r3f.attach === undefined) { - if (instance instanceof THREE.BufferGeometry) instance.__r3f.attach = 'geometry' - else if (instance instanceof THREE.Material) instance.__r3f.attach = 'material' - } - - // It should NOT call onUpdate on object instanciation, because it hasn't been added to the - // view yet. If the callback relies on references for instance, they won't be ready yet, this is - // why it passes "true" here - // There is no reason to apply props to injects - if (name !== 'inject') applyProps(instance, props) return instance } - function appendChild(parentInstance: HostConfig['instance'], child: HostConfig['instance']) { - let added = false - if (child) { - // The attach attribute implies that the object attaches itself on the parent - if (child.__r3f?.attach) { - attach(parentInstance, child, child.__r3f.attach) - } else if (child.isObject3D && parentInstance.isObject3D) { - // add in the usual parent-child way - parentInstance.add(child) - added = true - } - // This is for anything that used attach, and for non-Object3Ds that don't get attached to props; - // that is, anything that's a child in React but not a child in the scenegraph. - if (!added) parentInstance.__r3f?.objects.push(child) - if (!child.__r3f) prepare(child, {}) - child.__r3f.parent = parentInstance - updateInstance(child) - invalidateInstance(child) - } + function appendChild(parent: HostConfig['instance'], child: HostConfig['instance'] | HostConfig['textInstance']) { + if (!child) return + + child.parent = parent + parent.children.push(child) } function insertBefore( - parentInstance: HostConfig['instance'], - child: HostConfig['instance'], - beforeChild: HostConfig['instance'], + parent: HostConfig['instance'], + child: HostConfig['instance'] | HostConfig['textInstance'], + beforeChild: HostConfig['instance'] | HostConfig['textInstance'], ) { - let added = false - if (child) { - if (child.__r3f?.attach) { - attach(parentInstance, child, child.__r3f.attach) - } else if (child.isObject3D && parentInstance.isObject3D) { - child.parent = parentInstance as unknown as THREE.Object3D - child.dispatchEvent({ type: 'added' }) - const restSiblings = parentInstance.children.filter((sibling) => sibling !== child) - const index = restSiblings.indexOf(beforeChild) - parentInstance.children = [...restSiblings.slice(0, index), child, ...restSiblings.slice(index)] - added = true - } + if (!child || !beforeChild) return - if (!added) parentInstance.__r3f?.objects.push(child) - if (!child.__r3f) prepare(child, {}) - child.__r3f.parent = parentInstance - updateInstance(child) - invalidateInstance(child) - } + child.parent = parent + parent.children.splice(parent.children.indexOf(beforeChild), 0, child) } - function removeRecursive(array: HostConfig['instance'][], parent: HostConfig['instance'], dispose: boolean = false) { - if (array) [...array].forEach((child) => removeChild(parent, child, dispose)) + function removeRecursive( + children: HostConfig['instance'][], + parent: HostConfig['instance'], + dispose: boolean = false, + ) { + for (const child of children) { + removeChild(parent, child, dispose) + } } - function removeChild(parentInstance: HostConfig['instance'], child: HostConfig['instance'], dispose?: boolean) { - if (child) { - // Clear the parent reference - if (child.__r3f) child.__r3f.parent = null - // Remove child from the parents objects - if (parentInstance.__r3f?.objects) - parentInstance.__r3f.objects = parentInstance.__r3f.objects.filter((x) => x !== child) - // Remove attachment - if (child.__r3f?.attach) { - detach(parentInstance, child, child.__r3f.attach) - } else if (child.isObject3D && parentInstance.isObject3D) { - parentInstance.remove(child) - // Remove interactivity - if (child.__r3f?.root) { - removeInteractivity(child.__r3f.root, child as unknown as THREE.Object3D) - } - } + function removeChild( + parent: HostConfig['instance'], + child: HostConfig['instance'] | HostConfig['textInstance'], + dispose?: boolean, + ) { + if (!child) return - // Allow objects to bail out of recursive dispose altogether by passing dispose={null} - // Never dispose of primitives because their state may be kept outside of React! - // In order for an object to be able to dispose it has to have - // - a dispose method, - // - it cannot be a - // - it cannot be a THREE.Scene, because three has broken it's own api - // - // Since disposal is recursive, we can check the optional dispose arg, which will be undefined - // when the reconciler calls it, but then carry our own check recursively - const isPrimitive = child.__r3f?.primitive - const shouldDispose = dispose === undefined ? child.dispose !== null && !isPrimitive : dispose - - // Remove nested child objects. Primitives should not have objects and children that are - // attached to them declaratively ... - if (!isPrimitive) { - removeRecursive(child.__r3f?.objects, child, shouldDispose) - removeRecursive(child.children, child, shouldDispose) - } + child.parent = null + const childIndex = parent.children.indexOf(child) + if (childIndex !== -1) parent.children.splice(childIndex, 1) - // Remove references - if (child.__r3f) { - delete ((child as Partial).__r3f as Partial).root - delete ((child as Partial).__r3f as Partial).objects - delete ((child as Partial).__r3f as Partial).handlers - delete ((child as Partial).__r3f as Partial).memoizedProps - if (!isPrimitive) delete (child as Partial).__r3f - } + if (child.props.attach) { + detach(parent, child) + } else if (child.object.isObject3D && parent.object.isObject3D) { + parent.object.remove(child.object) + removeInteractivity(child.root, child.object as unknown as THREE.Object3D) + } - // Dispose item whenever the reconciler feels like it - if (shouldDispose && child.dispose && child.type !== 'Scene') { + // Allow objects to bail out of recursive dispose altogether by passing dispose={null} + // Never dispose of primitives because their state may be kept outside of React! + // In order for an object to be able to dispose it has to have + // - a dispose method, + // - it cannot be a + // - it cannot be a THREE.Scene, because three has broken its own api + // + // Since disposal is recursive, we can check the optional dispose arg, which will be undefined + // when the reconciler calls it, but then carry our own check recursively + const isPrimitive = child.type === 'primitive' + const shouldDispose = dispose ?? (!isPrimitive && child.props.dispose !== null) + + // Remove nested child objects. Primitives should not have objects and children that are + // attached to them declaratively ... + if (!isPrimitive) removeRecursive(child.children, child, shouldDispose) + + // Dispose object whenever the reconciler feels like it + if (child.type !== 'scene' && shouldDispose) { + const dispose = child.object.dispose + if (typeof dispose === 'function') { scheduleCallback(idlePriority, () => { try { - child.dispose() + dispose() } catch (e) { /* ... */ } }) } + } + delete child.object.__r3f + child.object = null + + if (dispose === undefined) invalidateInstance(child) + } + + function commitInstance(instance: HostConfig['instance']) { + // Create object + if (instance.type !== 'primitive') { + const name = `${instance.type[0].toUpperCase()}${instance.type.slice(1)}` + const target = catalogue[name] + + const { args = [] } = instance.props + instance.object = new target(...args) + } + + // Attach object instance handle + instance.object.__r3f = instance + + // Don't handle children for containers + if (!instance.parent) return + + // Auto-attach geometry and materials to meshes + if (!instance.props.attach) { + if (instance.type.endsWith('Geometry')) instance.props.attach = 'geometry' + else if (instance.type.endsWith('Material')) instance.props.attach = 'material' + } + + // Append children + for (const child of instance.children) { + if (child.props.attach) { + attach(instance, child) + } else if (child.object.isObject3D && instance.object.isObject3D) { + instance.object.add(child.object) + } + } - invalidateInstance(parentInstance) + // Append to parent + if (instance.parent.object) { + if (instance.props.attach) { + attach(instance.parent, instance) + } else if (instance.object.isObject3D && instance.parent.object.isObject3D) { + instance.parent.object.add(instance.object) + } } + + // Apply props to object + applyProps(instance.object, instance.props) + + invalidateInstance(instance) } function switchInstance( - instance: HostConfig['instance'], + oldInstance: HostConfig['instance'], type: HostConfig['type'], - newProps: HostConfig['props'], + props: HostConfig['props'], fiber: Reconciler.Fiber, ) { - const parent = instance.__r3f?.parent - if (!parent) return - - const newInstance = createInstance(type, newProps, instance.__r3f.root) + // Create a new instance + const newInstance = createInstance(type, props, oldInstance.root) - // https://github.com/pmndrs/react-three-fiber/issues/1348 - // When args change the instance has to be re-constructed, which then - // forces r3f to re-parent the children and non-scene objects - if (instance.children) { - for (const child of instance.children) { - if (child.__r3f) appendChild(newInstance, child) - } - instance.children = instance.children.filter((child) => !child.__r3f) - } + // Link up new instance + const parent = oldInstance.parent! + appendChild(parent, newInstance) - instance.__r3f.objects.forEach((child) => appendChild(newInstance, child)) - instance.__r3f.objects = [] + // Commit new instance object + commitInstance(newInstance) - removeChild(parent, instance) - appendChild(parent, newInstance) + // Append to scene-graph + if (parent.parent) { + if (newInstance.props.attach) attach(parent, newInstance) + else if (newInstance.object.isObject3D) parent.object.add(newInstance.object) + } - // Re-bind event handlers - if (newInstance.raycast && newInstance.__r3f.eventCount) { - const rootState = newInstance.__r3f.root.getState() - rootState.internal.interaction.push(newInstance as unknown as THREE.Object3D) + // Move children to new instance + for (const child of oldInstance.children) { + appendChild(newInstance, child) + if (child.props.attach) { + detach(oldInstance, child) + attach(newInstance, child) + } else if (child.object.isObject3D && oldInstance.object.isObject3D) { + oldInstance.object.remove(child.object) + newInstance.object.add(child.object) + } } + // Cleanup old instance + oldInstance.children = [] + removeChild(parent, oldInstance) + // This evil hack switches the react-internal fiber node // https://github.com/facebook/react/issues/14983 // https://github.com/facebook/react/pull/15021 @@ -284,11 +262,13 @@ function createRenderer(_roots: Map, _getEventPriority?: if (fiber !== null) { fiber.stateNode = newInstance if (fiber.ref) { - if (typeof fiber.ref === 'function') (fiber as unknown as any).ref(newInstance) - else (fiber.ref as Reconciler.RefObject).current = newInstance + if (typeof fiber.ref === 'function') (fiber as unknown as any).ref(newInstance.object) + else (fiber.ref as Reconciler.RefObject).current = newInstance.object } } }) + + return newInstance } // Don't handle text instances, warn on undefined behavior @@ -310,94 +290,72 @@ function createRenderer(_roots: Map, _getEventPriority?: HostConfig['timeoutHandle'], HostConfig['noTimeout'] >({ - createInstance, - removeChild, - appendChild, - appendInitialChild: appendChild, - insertBefore, supportsMutation: true, isPrimaryRenderer: false, supportsPersistence: false, supportsHydration: false, noTimeout: -1, - appendChildToContainer: (container, child) => { - if (!child) return - - const scene = container.getState().scene as unknown as Instance - // Link current root to the default scene - scene.__r3f.root = container - appendChild(scene, child) - }, - removeChildFromContainer: (container, child) => { - if (!child) return - removeChild(container.getState().scene as unknown as Instance, child) - }, - insertInContainerBefore: (container, child, beforeChild) => { - if (!child || !beforeChild) return - insertBefore(container.getState().scene as unknown as Instance, child, beforeChild) - }, + createInstance, + removeChild, + appendChild, + appendInitialChild: appendChild, + insertBefore, + appendChildToContainer: () => {}, + removeChildFromContainer: () => {}, + insertInContainerBefore: () => {}, getRootHostContext: () => null, getChildHostContext: (parentHostContext) => parentHostContext, - finalizeInitialChildren(instance) { - const localState = instance?.__r3f ?? {} - // https://github.com/facebook/react/issues/20271 - // Returning true will trigger commitMount - return Boolean(localState.handlers) - }, prepareUpdate(instance, _type, oldProps, newProps) { - // Create diff-sets - if (instance.__r3f.primitive && newProps.object && newProps.object !== instance) { - return [true] - } else { - // This is a data object, let's extract critical information about it - const { args: argsNew = [], children: cN, ...restNew } = newProps - const { args: argsOld = [], children: cO, ...restOld } = oldProps - - // Throw if an object or literal was passed for args - if (!Array.isArray(argsNew)) throw new Error('R3F: the args prop must be an array!') - - // If it has new props or arguments, then it needs to be re-instantiated - if (argsNew.some((value, index) => value !== argsOld[index])) return [true] - // Create a diff-set, flag if there are any changes - const diff = diffProps(instance, restNew, restOld, true) - if (diff.changes.length) return [false, diff] - - // Otherwise do not touch the instance - return null - } + // Reconstruct primitives if object prop changes + if (instance.type === 'primitive' && oldProps.object !== newProps.object) return [true] + // Reconstruct elements if args change + if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) return [true] + + // Create a diff-set, flag if there are any changes + const changedProps = diffProps(newProps, oldProps, true) + if (Object.keys(changedProps).length) return [false, changedProps] + + // Otherwise do not touch the instance + return null }, - commitUpdate(instance, [reconstruct, diff]: [boolean, DiffSet], type, _oldProps, newProps, fiber) { + commitUpdate(instance, diff, type, _oldProps, newProps, fiber) { + const [reconstruct, changedProps] = diff! + // Reconstruct when args or instance!, + finalizeInitialChildren: () => true, + commitMount: commitInstance, + getPublicInstance: (instance) => instance?.object, prepareForCommit: () => null, - preparePortalMount: (container) => prepare(container.getState().scene), + preparePortalMount: (container) => container, resetAfterCommit: () => {}, shouldSetTextContent: () => false, clearContainer: () => false, hideInstance(instance) { - // Detach while the instance is hidden - const { attach: type, parent } = instance.__r3f ?? {} - if (type && parent) detach(parent, instance, type) - if (instance.isObject3D) instance.visible = false + if (!instance.object) return + + if (instance.props.attach && instance.parent?.object) { + detach(instance.parent, instance) + } else if (instance.object.isObject3D) { + instance.object.visible = false + } + invalidateInstance(instance) }, - unhideInstance(instance, props) { - // Re-attach when the instance is unhidden - const { attach: type, parent } = instance.__r3f ?? {} - if (type && parent) attach(parent, instance, type) - if ((instance.isObject3D && props.visible == null) || props.visible) instance.visible = true + unhideInstance(instance) { + if (!instance.object) return + + if (instance.props.attach && instance.parent?.object) { + attach(instance.parent, instance) + } else if (instance.object.isObject3D && instance.props.visible !== false) { + instance.object.visible = true + } + invalidateInstance(instance) }, createTextInstance: handleTextInstance, @@ -423,4 +381,4 @@ function createRenderer(_roots: Map, _getEventPriority?: return { reconciler, applyProps } } -export { prepare, createRenderer, extend } +export { createRenderer, extend } diff --git a/packages/fiber/src/core/store.ts b/packages/fiber/src/core/store.ts index b1a17881bf..b0e6ee163a 100644 --- a/packages/fiber/src/core/store.ts +++ b/packages/fiber/src/core/store.ts @@ -1,7 +1,6 @@ import * as THREE from 'three' import * as React from 'react' import create, { GetState, SetState, StoreApi, UseBoundStore } from 'zustand' -import { prepare } from './renderer' import { DomEvent, EventManager, PointerCaptureTarget, ThreeEvent } from './events' import { calculateDpr, Camera, isOrthographicCamera, updateCamera } from './utils' import { FixedStage, Stage } from './stages' @@ -219,7 +218,7 @@ const createStore = ( legacy: false, linear: false, flat: false, - scene: prepare(new THREE.Scene()), + scene: new THREE.Scene(), controls: null, clock: new THREE.Clock(), diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 80e2ce0da5..a1ea92c11e 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -1,8 +1,7 @@ import * as THREE from 'three' import * as React from 'react' -import { UseBoundStore } from 'zustand' import { EventHandlers } from './events' -import { AttachType, Instance, InstanceProps, LocalState } from './renderer' +import { Instance, InstanceProps } from './renderer' import { Dpr, RootState, Size } from './store' export type Camera = THREE.OrthographicCamera | THREE.PerspectiveCamera @@ -54,14 +53,6 @@ export class ErrorBoundary extends React.Component< } } -export const DEFAULT = '__default' - -export type DiffSet = { - memoized: { [key: string]: any } - changes: [key: string, value: unknown, isEvent: boolean, keys: string[]][] -} - -export const isDiffSet = (def: any): def is DiffSet => def && !!(def as DiffSet).memoized && !!(def as DiffSet).changes export type ClassConstructor = { new (): void } export type ObjectMap = { @@ -77,7 +68,7 @@ export function calculateDpr(dpr: Dpr) { * Returns instance root state */ export const getRootState = (obj: THREE.Object3D): RootState | undefined => - (obj as unknown as Instance).__r3f?.root.getState() + ((obj as any).__r3f as Instance)?.root.getState() export type EquConfig = { /** Compare arrays by reference equality a === b (default), or by shallow equality */ @@ -142,222 +133,194 @@ export function dispose void; type?: string; [key } } -// Each object in the scene carries a small LocalState descriptor -export function prepare(object: T, state?: Partial) { - const instance = object as unknown as Instance - if (state?.primitive || !instance.__r3f) { - instance.__r3f = { - type: '', - root: null as unknown as UseBoundStore, - previousAttach: null, - memoizedProps: {}, - eventCount: 0, - handlers: {}, - objects: [], - parent: null, - ...state, - } - } - return object -} +function resolve(root: any, key: string) { + let target = root[key] + if (!key.includes('-')) return { root, key, target } + + // Resolve pierced target + const chain = key.split('-') + target = chain.reduce((acc, key) => acc[key], root) + key = chain.pop()! -function resolve(instance: Instance, key: string) { - let target = instance - if (key.includes('-')) { - const entries = key.split('-') - const last = entries.pop() as string - target = entries.reduce((acc, key) => acc[key], instance) - return { target, key: last } - } else return { target, key } + // Switch root if atomic + if (!target?.set) root = chain.reduce((acc, key) => acc[key], root) + + return { root, key, target } } // Checks if a dash-cased string ends with an integer const INDEX_REGEX = /-\d+$/ -export function attach(parent: Instance, child: Instance, type: AttachType) { - if (is.str(type)) { +export function attach(parent: Instance, child: Instance) { + if (is.str(child.props.attach)) { // If attaching into an array (foo-0), create one - if (INDEX_REGEX.test(type)) { - const root = type.replace(INDEX_REGEX, '') - const { target, key } = resolve(parent, root) - if (!Array.isArray(target[key])) target[key] = [] + if (INDEX_REGEX.test(child.props.attach)) { + const index = child.props.attach.replace(INDEX_REGEX, '') + const { root, key } = resolve(parent.object, index) + if (!Array.isArray(root[key])) root[key] = [] } - const { target, key } = resolve(parent, type) - child.__r3f.previousAttach = target[key] - target[key] = child - } else child.__r3f.previousAttach = type(parent, child) + const { root, key } = resolve(parent.object, child.props.attach) + child.previousAttach = root[key] + root[key] = child.object + } else if (is.fun(child.props.attach)) { + child.previousAttach = child.props.attach(parent.object, child.object) + } } -export function detach(parent: Instance, child: Instance, type: AttachType) { - if (is.str(type)) { - const { target, key } = resolve(parent, type) - const previous = child.__r3f.previousAttach +export function detach(parent: Instance, child: Instance) { + if (is.str(child.props.attach)) { + const { root, key } = resolve(parent.object, child.props.attach) + const previous = child.previousAttach // When the previous value was undefined, it means the value was never set to begin with - if (previous === undefined) delete target[key] + if (previous === undefined) delete root[key] // Otherwise set the previous value - else target[key] = previous - } else child.__r3f?.previousAttach?.(parent, child) - delete child.__r3f?.previousAttach + else root[key] = previous + } else { + child.previousAttach?.(parent.object, child.object) + } + + delete child.previousAttach } +const DEFAULT = '__default' +const RESERVED_PROPS = [ + // React internal props + 'children', + 'key', + 'ref', + // Instance props + 'args', + 'dispose', + 'attach', + // 'object', -- internal to primitives +] + // This function prepares a set of changes to be applied to the instance -export function diffProps( - instance: Instance, - { children: cN, key: kN, ref: rN, ...props }: InstanceProps, - { children: cP, key: kP, ref: rP, ...previous }: InstanceProps = {}, - remove = false, -): DiffSet { - const localState = (instance?.__r3f ?? {}) as LocalState - const entries = Object.entries(props) - const changes: [key: string, value: unknown, isEvent: boolean, keys: string[]][] = [] +export function diffProps(newProps: InstanceProps, oldProps: InstanceProps, remove = false): InstanceProps { + const changedProps: InstanceProps = {} + + // Sort through props + for (const key in newProps) { + // Skip reserved keys + if (RESERVED_PROPS.includes(key)) continue + // Skip if props match + if (is.equ(newProps[key], oldProps[key])) continue + + // Props changed, add them + changedProps[key] = newProps[key] + } // Catch removed props, prepend them so they can be reset or removed if (remove) { - const previousKeys = Object.keys(previous) - for (let i = 0; i < previousKeys.length; i++) { - if (!props.hasOwnProperty(previousKeys[i])) entries.unshift([previousKeys[i], DEFAULT + 'remove']) + for (const key in oldProps) { + if (RESERVED_PROPS.includes(key)) continue + else if (!newProps.hasOwnProperty(key)) changedProps[key] = DEFAULT + 'remove' } } - entries.forEach(([key, value]) => { - // Bail out on primitive object - if (instance.__r3f?.primitive && key === 'object') return - // When props match bail out - if (is.equ(value, previous[key])) return - // Collect handlers and bail out - if (/^on(Pointer|Click|DoubleClick|ContextMenu|Wheel)/.test(key)) return changes.push([key, value, true, []]) - // Split dashed props - let entries: string[] = [] - if (key.includes('-')) entries = key.split('-') - changes.push([key, value, false, entries]) - }) - - const memoized: { [key: string]: any } = { ...props } - if (localState.memoizedProps && localState.memoizedProps.args) memoized.args = localState.memoizedProps.args - if (localState.memoizedProps && localState.memoizedProps.attach) memoized.attach = localState.memoizedProps.attach - - return { memoized, changes } + return changedProps } // This function applies a set of changes to the instance -export function applyProps(instance: Instance, data: InstanceProps | DiffSet) { - // Filter equals, events and reserved props - const localState = (instance.__r3f ?? {}) as LocalState - const root = localState.root - const rootState = root?.getState?.() ?? {} - const { memoized, changes } = isDiffSet(data) ? data : diffProps(instance, data) - const prevHandlers = localState.eventCount - - // Prepare memoized props - if (instance.__r3f) instance.__r3f.memoizedProps = memoized - - changes.forEach(([key, value, isEvent, keys]) => { - let currentInstance = instance - let targetProp = currentInstance[key] - - // Revolve dashed props - if (keys.length) { - targetProp = keys.reduce((acc, key) => acc[key], instance) - // If the target is atomic, it forces us to switch the root - if (!(targetProp && targetProp.set)) { - const [name, ...reverseEntries] = keys.reverse() - currentInstance = reverseEntries.reverse().reduce((acc, key) => acc[key], instance) - key = name - } +export function applyProps(object: any, props: any) { + const instance = object.__r3f as Instance | undefined + const rootState = instance?.root.getState() + const prevHandlers = instance?.eventCount + + for (const prop in props) { + let value = props[prop] + + // Don't mutate reserved keys + if (RESERVED_PROPS.includes(prop)) continue + + // Deal with pointer events ... + if (instance && /^on(Pointer|Click|DoubleClick|ContextMenu|Wheel)/.test(prop)) { + if (value) instance.handlers[prop as keyof EventHandlers] = value as any + else delete instance.handlers[prop as keyof EventHandlers] + instance.eventCount = Object.keys(instance.handlers).length } + const { root, key, target } = resolve(object, prop) + // https://github.com/mrdoob/three.js/issues/21209 // HMR/fast-refresh relies on the ability to cancel out props, but threejs // has no means to do this. Hence we curate a small collection of value-classes // with their respective constructor/set arguments // For removed props, try to set default values, if possible if (value === DEFAULT + 'remove') { - if (targetProp && targetProp.constructor) { + if (target && target.constructor) { // use the prop constructor to find the default it should be - value = new targetProp.constructor(...(memoized.args ?? [])) - } else if (currentInstance.constructor) { + value = new target.constructor(...(instance?.props.args ?? [])) + } else if (root.constructor) { // create a blank slate of the instance and copy the particular parameter. // @ts-ignore - const defaultClassCall = new currentInstance.constructor(...(currentInstance.__r3f.memoizedProps.args ?? [])) - value = defaultClassCall[targetProp] - // destory the instance + const defaultClassCall = new root.constructor(...(root.__r3f?.props.args ?? [])) + value = defaultClassCall[target] + // destroy the instance if (defaultClassCall.dispose) defaultClassCall.dispose() - // instance does not have constructor, just set it to 0 } else { + // instance does not have constructor, just set it to 0 value = 0 } } - // Deal with pointer events ... - if (isEvent) { - if (value) localState.handlers[key as keyof EventHandlers] = value as any - else delete localState.handlers[key as keyof EventHandlers] - localState.eventCount = Object.keys(localState.handlers).length - } // Special treatment for objects with support for set/copy, and layers - else if (targetProp && targetProp.set && (targetProp.copy || targetProp instanceof THREE.Layers)) { + if (target && target.set && (target.copy || target instanceof THREE.Layers)) { // If value is an array if (Array.isArray(value)) { - if (targetProp.fromArray) targetProp.fromArray(value) - else targetProp.set(...value) + if (target.fromArray) target.fromArray(value) + else target.set(...value) } // Test again target.copy(class) next ... else if ( - targetProp.copy && + target.copy && value && (value as ClassConstructor).constructor && - targetProp.constructor.name === (value as ClassConstructor).constructor.name + target.constructor.name === (value as ClassConstructor).constructor.name ) { - targetProp.copy(value) + target.copy(value) } // If nothing else fits, just set the single value, ignore undefined // https://github.com/pmndrs/react-three-fiber/issues/274 else if (value !== undefined) { - const isColor = targetProp instanceof THREE.Color + const isColor = target instanceof THREE.Color // Allow setting array scalars - if (!isColor && targetProp.setScalar) targetProp.setScalar(value) + if (!isColor && target.setScalar) target.setScalar(value) // Layers have no copy function, we must therefore copy the mask property - else if (targetProp instanceof THREE.Layers && value instanceof THREE.Layers) targetProp.mask = value.mask + else if (target instanceof THREE.Layers && value instanceof THREE.Layers) target.mask = value.mask // Otherwise just set ... - else targetProp.set(value) + else target.set(value) } // Else, just overwrite the value } else { - currentInstance[key] = value + root[key] = value // Auto-convert sRGB textures, for now ... // https://github.com/pmndrs/react-three-fiber/issues/344 - if (!rootState.linear && currentInstance[key] instanceof THREE.Texture) { - currentInstance[key].encoding = THREE.sRGBEncoding + if (!rootState?.linear && root[key] instanceof THREE.Texture) { + root[key].encoding = THREE.sRGBEncoding } } + } - invalidateInstance(instance) - }) - - if (localState.parent && rootState.internal && instance.raycast && prevHandlers !== localState.eventCount) { + if (instance?.parent && rootState?.internal && instance?.object.raycast && prevHandlers !== instance?.eventCount) { // Pre-emptively remove the instance from the interaction manager - const index = rootState.internal.interaction.indexOf(instance as unknown as THREE.Object3D) + const index = rootState.internal.interaction.indexOf(instance.object as unknown as THREE.Object3D) if (index > -1) rootState.internal.interaction.splice(index, 1) // Add the instance to the interaction manager only when it has handlers - if (localState.eventCount) rootState.internal.interaction.push(instance as unknown as THREE.Object3D) + if (instance.eventCount) rootState.internal.interaction.push(instance.object as unknown as THREE.Object3D) } - // Call the update lifecycle when it is being updated, but only when it is part of the scene - if (changes.length && instance.parent) updateInstance(instance) + if (instance) invalidateInstance(instance) - return instance + return object } export function invalidateInstance(instance: Instance) { - const state = instance.__r3f?.root?.getState?.() + const state = instance.root?.getState?.() if (state && state.internal.frames === 0) state.invalidate() } -export function updateInstance(instance: Instance) { - instance.onUpdate?.(instance) -} - export function updateCamera(camera: Camera & { manual?: boolean }, size: Size) { // https://github.com/pmndrs/react-three-fiber/issues/92 // Do not mess with the camera if it belongs to the user diff --git a/packages/fiber/src/index.tsx b/packages/fiber/src/index.tsx index 9e0894fb4d..42388801c1 100644 --- a/packages/fiber/src/index.tsx +++ b/packages/fiber/src/index.tsx @@ -1,7 +1,7 @@ export * from './three-types' import * as ReactThreeFiber from './three-types' export { ReactThreeFiber } -export type { BaseInstance, LocalState } from './core/renderer' +export type { Instance } from './core/renderer' export type { Intersection, Subscription, diff --git a/packages/fiber/src/native.tsx b/packages/fiber/src/native.tsx index 667a0596e5..014371c76b 100644 --- a/packages/fiber/src/native.tsx +++ b/packages/fiber/src/native.tsx @@ -1,7 +1,7 @@ export * from './three-types' import * as ReactThreeFiber from './three-types' export { ReactThreeFiber } -export type { BaseInstance, LocalState } from './core/renderer' +export type { Instance } from './core/renderer' export type { Intersection, Subscription, diff --git a/packages/fiber/tests/core/renderer.test.tsx b/packages/fiber/tests/core/renderer.test.tsx index c1ba32ed34..54dc05c0ec 100644 --- a/packages/fiber/tests/core/renderer.test.tsx +++ b/packages/fiber/tests/core/renderer.test.tsx @@ -16,6 +16,7 @@ import { import { UseBoundStore } from 'zustand' import { privateKeys, RootState } from '../../src/core/store' import { Instance } from '../../src/core/renderer' +import { suspend } from 'suspend-react' type ComponentMesh = THREE.Mesh @@ -738,4 +739,23 @@ describe('renderer', () => { const respectedKeys = privateKeys.filter((key) => overwrittenKeys.includes(key) || state[key] === portalState[key]) expect(respectedKeys).toStrictEqual(privateKeys) }) + + it('should gracefully handle text', async () => { + // Mount + await act(async () => root.render(<>one)) + // Update + await act(async () => root.render(<>two)) + // Unmount + await act(async () => root.render(<>)) + + // Suspense + const Test = () => suspend(async () => null, []) + await act(async () => { + root.render( + + + , + ) + }) + }) }) diff --git a/packages/test-renderer/src/__tests__/RTTR.core.test.tsx b/packages/test-renderer/src/__tests__/RTTR.core.test.tsx index f8354422d7..466073479c 100644 --- a/packages/test-renderer/src/__tests__/RTTR.core.test.tsx +++ b/packages/test-renderer/src/__tests__/RTTR.core.test.tsx @@ -82,19 +82,7 @@ describe('ReactThreeTestRenderer Core', () => { const renderer = await ReactThreeTestRenderer.create() - expect(renderer.toGraph()).toEqual([ - { - type: 'Group', - name: '', - children: [ - { - type: 'Mesh', - name: '', - children: [], - }, - ], - }, - ]) + expect(renderer.toGraph()).toMatchSnapshot() }) it('renders some basics with an update', async () => { @@ -167,7 +155,7 @@ describe('ReactThreeTestRenderer Core', () => { }) it('exposes the instance', async () => { - class Mesh extends React.PureComponent { + class Instance extends React.PureComponent { state = { standardMat: false } handleStandard() { @@ -184,51 +172,17 @@ describe('ReactThreeTestRenderer Core', () => { } } - const renderer = await ReactThreeTestRenderer.create() - - expect(renderer.toTree()).toEqual([ - { - type: 'mesh', - props: { - args: [], - }, - children: [ - { type: 'boxGeometry', props: { args: [2, 2] }, children: [] }, - { - type: 'meshBasicMaterial', - props: { - args: [], - }, - children: [], - }, - ], - }, - ]) + const renderer = await ReactThreeTestRenderer.create() + + expect(renderer.toTree()).toMatchSnapshot() - const instance = renderer.getInstance() as Mesh + const instance = renderer.getInstance() as Instance await ReactThreeTestRenderer.act(async () => { instance.handleStandard() }) - expect(renderer.toTree()).toEqual([ - { - type: 'mesh', - props: { - args: [], - }, - children: [ - { type: 'boxGeometry', props: { args: [2, 2] }, children: [] }, - { - type: 'meshStandardMaterial', - props: { - args: [], - }, - children: [], - }, - ], - }, - ]) + expect(renderer.toTree()).toMatchSnapshot() }) it('updates children', async () => { @@ -316,15 +270,7 @@ describe('ReactThreeTestRenderer Core', () => { ) const renderer = await ReactThreeTestRenderer.create() - expect(renderer.toTree()).toEqual([ - { - type: 'group', - props: { - args: [], - }, - children: [], - }, - ]) + expect(renderer.toTree()).toMatchSnapshot() }) it('correctly builds a tree', async () => { diff --git a/packages/test-renderer/src/__tests__/RTTR.events.test.tsx b/packages/test-renderer/src/__tests__/RTTR.events.test.tsx index 01b31b9bc6..c2254c799a 100644 --- a/packages/test-renderer/src/__tests__/RTTR.events.test.tsx +++ b/packages/test-renderer/src/__tests__/RTTR.events.test.tsx @@ -50,8 +50,13 @@ describe('ReactThreeTestRenderer Events', () => { const { scene, fireEvent } = await ReactThreeTestRenderer.create() - expect(async () => await fireEvent(scene.children[0], 'onPointerUp')).not.toThrow() + const warn = console.warn.bind(console) + console.warn = jest.fn() + expect(async () => await fireEvent(scene.children[0], 'onPointerUp')).not.toThrow() + expect(console.warn).toBeCalled() expect(handlePointerDown).not.toHaveBeenCalled() + + console.warn = warn }) }) diff --git a/packages/test-renderer/src/__tests__/__snapshots__/RTTR.core.test.tsx.snap b/packages/test-renderer/src/__tests__/__snapshots__/RTTR.core.test.tsx.snap index bc4b647a4b..aab79e87fb 100644 --- a/packages/test-renderer/src/__tests__/__snapshots__/RTTR.core.test.tsx.snap +++ b/packages/test-renderer/src/__tests__/__snapshots__/RTTR.core.test.tsx.snap @@ -1,9 +1,53 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`ReactThreeTestRenderer Core can render a composite component & correctly build simple graph 1`] = ` +Array [ + Object { + "children": Array [ + Object { + "children": Array [], + "name": "", + "type": undefined, + }, + Object { + "children": Array [ + Object { + "children": Array [], + "name": "", + "type": "BoxGeometry", + }, + Object { + "children": Array [], + "name": "", + "type": "MeshBasicMaterial", + }, + ], + "name": "", + "type": "Mesh", + }, + ], + "name": "", + "type": "Group", + }, +] +`; + exports[`ReactThreeTestRenderer Core correctly builds a tree 1`] = ` Array [ Object { "children": Array [ + Object { + "children": Array [], + "props": Object { + "args": Array [ + 0, + 0, + 255, + ], + "attach": "background", + }, + "type": "color", + }, Object { "children": Array [ Object { @@ -32,6 +76,7 @@ Array [ -1, 1, ], + "attach": "attributes-position", "count": 6, "itemSize": 3, }, @@ -40,6 +85,34 @@ Array [ ], "props": Object { "args": Array [], + "attach": "geometry", + "children": , }, "type": "bufferGeometry", }, @@ -47,6 +120,7 @@ Array [ "children": Array [], "props": Object { "args": Array [], + "attach": "material", "color": "hotpink", }, "type": "meshBasicMaterial", @@ -54,30 +128,155 @@ Array [ ], "props": Object { "args": Array [], + "children": Array [ + + + , + , + ], }, "type": "mesh", }, + ], + "props": Object { + "args": Array [], + "children": Array [ + , + , + , + ], + "position": Array [ + 1, + 2, + 3, + ], + }, + "type": "group", + }, +] +`; + +exports[`ReactThreeTestRenderer Core exposes the instance 1`] = ` +Array [ + Object { + "children": Array [ Object { "children": Array [], "props": Object { "args": Array [ - 0, - 0, - 255, + 2, + 2, ], + "attach": "geometry", }, - "type": "color", + "type": "boxGeometry", + }, + Object { + "children": Array [], + "props": Object { + "args": Array [], + "attach": "material", + }, + "type": "meshBasicMaterial", }, ], "props": Object { "args": Array [], - "position": Array [ - 1, - 2, - 3, + "children": Array [ + , + , ], }, - "type": "group", + "type": "mesh", + }, +] +`; + +exports[`ReactThreeTestRenderer Core exposes the instance 2`] = ` +Array [ + Object { + "children": Array [ + Object { + "children": Array [], + "props": Object { + "args": Array [ + 2, + 2, + ], + "attach": "geometry", + }, + "type": "boxGeometry", + }, + Object { + "children": Array [], + "props": Object { + "args": Array [], + "attach": "material", + }, + "type": "meshStandardMaterial", + }, + ], + "props": Object { + "args": Array [], + "children": Array [ + , + , + ], + }, + "type": "mesh", }, ] `; @@ -94,12 +293,23 @@ Array [ 0, 0, ], + "attach": "background", }, "type": "color", }, ], "props": Object { "args": Array [], + "children": , }, "type": "group", }, @@ -113,12 +323,23 @@ Array [ 0, 255, ], + "attach": "background", }, "type": "color", }, ], "props": Object { "args": Array [], + "children": , }, "type": "group", }, @@ -132,10 +353,33 @@ Array [ 0, 0, ], + "attach": "background", }, "type": "color", }, ], + "props": Object { + "args": Array [], + "children": , + }, + "type": "group", + }, +] +`; + +exports[`ReactThreeTestRenderer Core toTree() handles nested Fragments 1`] = ` +Array [ + Object { + "children": Array [], "props": Object { "args": Array [], }, @@ -157,6 +401,7 @@ Array [ 2, 2, ], + "attach": "geometry", }, "type": "boxGeometry", }, @@ -164,12 +409,24 @@ Array [ "children": Array [], "props": Object { "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { "args": Array [], + "children": Array [ + , + , + ], "position-z": 12, }, "type": "mesh", @@ -183,6 +440,7 @@ Array [ 4, 4, ], + "attach": "geometry", }, "type": "boxGeometry", }, @@ -190,12 +448,24 @@ Array [ "children": Array [], "props": Object { "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { "args": Array [], + "children": Array [ + , + , + ], "position-y": 12, }, "type": "mesh", @@ -209,6 +479,7 @@ Array [ 6, 6, ], + "attach": "geometry", }, "type": "boxGeometry", }, @@ -216,12 +487,24 @@ Array [ "children": Array [], "props": Object { "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { "args": Array [], + "children": Array [ + , + , + ], "position-x": 12, }, "type": "mesh", @@ -229,6 +512,47 @@ Array [ ], "props": Object { "args": Array [], + "children": Array [ + + + + , + + + + , + + + + , + ], }, "type": "group", }, @@ -248,6 +572,7 @@ Array [ 6, 6, ], + "attach": "geometry", }, "type": "boxGeometry", }, @@ -255,12 +580,24 @@ Array [ "children": Array [], "props": Object { "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { "args": Array [], + "children": Array [ + , + , + ], "rotation-x": 1, }, "type": "mesh", @@ -274,6 +611,7 @@ Array [ 4, 4, ], + "attach": "geometry", }, "type": "boxGeometry", }, @@ -281,12 +619,24 @@ Array [ "children": Array [], "props": Object { "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { "args": Array [], + "children": Array [ + , + , + ], "position-y": 12, }, "type": "mesh", @@ -296,23 +646,36 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [ - 2, - 2, - ], + "args": Array [], + "attach": "material", }, - "type": "boxGeometry", + "type": "meshBasicMaterial", }, Object { "children": Array [], "props": Object { - "args": Array [], + "args": Array [ + 2, + 2, + ], + "attach": "geometry", }, - "type": "meshBasicMaterial", + "type": "boxGeometry", }, ], "props": Object { "args": Array [], + "children": Array [ + , + , + ], "position-x": 12, }, "type": "mesh", @@ -320,6 +683,47 @@ Array [ ], "props": Object { "args": Array [], + "children": Array [ + + + + , + + + + , + + + + , + ], }, "type": "group", }, diff --git a/packages/test-renderer/src/createTestInstance.ts b/packages/test-renderer/src/createTestInstance.ts index 69a4b3b28c..b0ed075206 100644 --- a/packages/test-renderer/src/createTestInstance.ts +++ b/packages/test-renderer/src/createTestInstance.ts @@ -1,30 +1,34 @@ import { Object3D } from 'three' -import type { MockInstance, MockScene, Obj, TestInstanceChildOpts } from './types/internal' +import type { MockInstance, Obj, TestInstanceChildOpts } from './types/internal' import { expectOne, matchProps, findAll } from './helpers/testInstance' -export class ReactThreeTestInstance { - _fiber: MockInstance +export class ReactThreeTestInstance { + _fiber: MockInstance - constructor(fiber: MockInstance | MockScene) { - this._fiber = fiber as MockInstance + constructor(fiber: MockInstance) { + this._fiber = fiber } - public get instance(): Object3D { - return this._fiber as unknown as TInstance + public get fiber(): MockInstance { + return this._fiber + } + + public get instance(): TObject { + return this._fiber.object } public get type(): string { - return this._fiber.type + return this._fiber.object.type } public get props(): Obj { - return this._fiber.__r3f.memoizedProps + return this._fiber.props } public get parent(): ReactThreeTestInstance | null { - const parent = this._fiber.__r3f.parent + const parent = this._fiber.parent if (parent !== null) { return wrapFiber(parent) } @@ -42,20 +46,10 @@ export class ReactThreeTestInstance { private getChildren = ( fiber: MockInstance, opts: TestInstanceChildOpts = { exhaustive: false }, - ): ReactThreeTestInstance[] => { - if (opts.exhaustive) { - /** - * this will return objects like - * color or effects etc. - */ - return [ - ...(fiber.children || []).map((fib) => wrapFiber(fib as MockInstance)), - ...fiber.__r3f.objects.map((fib) => wrapFiber(fib as MockInstance)), - ] - } else { - return (fiber.children || []).map((fib) => wrapFiber(fib as MockInstance)) - } - } + ): ReactThreeTestInstance[] => + fiber.children + .filter((child) => !child.props.attach || opts.exhaustive) + .map((fib) => wrapFiber(fib as MockInstance)) public find = (decider: (node: ReactThreeTestInstance) => boolean): ReactThreeTestInstance => expectOne(findAll(this, decider), `matching custom checker: ${decider.toString()}`) @@ -79,8 +73,8 @@ export class ReactThreeTestInstance { findAll(this, (node: ReactThreeTestInstance) => Boolean(node.props && matchProps(node.props, props))) } -const fiberToWrapper = new WeakMap() -export const wrapFiber = (fiber: MockInstance | MockScene): ReactThreeTestInstance => { +const fiberToWrapper = new WeakMap() +export const wrapFiber = (fiber: MockInstance): ReactThreeTestInstance => { let wrapper = fiberToWrapper.get(fiber) if (wrapper === undefined) { wrapper = new ReactThreeTestInstance(fiber) diff --git a/packages/test-renderer/src/helpers/graph.ts b/packages/test-renderer/src/helpers/graph.ts index 0266dbaa11..7ab0da38cf 100644 --- a/packages/test-renderer/src/helpers/graph.ts +++ b/packages/test-renderer/src/helpers/graph.ts @@ -1,4 +1,4 @@ -import type { MockScene, MockSceneChild } from '../types/internal' +import type { MockInstance } from '../types/internal' import type { SceneGraphItem } from '../types/public' const graphObjectFactory = ( @@ -11,5 +11,5 @@ const graphObjectFactory = ( children, }) -export const toGraph = (object: MockScene | MockSceneChild): SceneGraphItem[] => - object.children.map((child) => graphObjectFactory(child.type, child.name || '', toGraph(child))) +export const toGraph = (object: MockInstance): SceneGraphItem[] => + object.children.map((child) => graphObjectFactory(child.object.type, child.object.name ?? '', toGraph(child))) diff --git a/packages/test-renderer/src/helpers/tree.ts b/packages/test-renderer/src/helpers/tree.ts index d69f20f2f8..45ca6890a6 100644 --- a/packages/test-renderer/src/helpers/tree.ts +++ b/packages/test-renderer/src/helpers/tree.ts @@ -1,5 +1,5 @@ import type { TreeNode, Tree } from '../types/public' -import type { MockSceneChild, MockScene } from '../types/internal' +import type { MockInstance } from '../types/internal' import { lowerCaseFirstLetter } from './strings' const treeObjectFactory = ( @@ -12,20 +12,13 @@ const treeObjectFactory = ( children, }) -const toTreeBranch = (obj: MockSceneChild[]): TreeNode[] => - obj.map((child) => { +const toTreeBranch = (children: MockInstance[]): TreeNode[] => + children.map((child) => { return treeObjectFactory( - lowerCaseFirstLetter(child.type || child.constructor.name), - { ...child.__r3f.memoizedProps }, - toTreeBranch([...(child.children || []), ...child.__r3f.objects]), + lowerCaseFirstLetter(child.object.type || child.object.constructor.name), + child.props, + toTreeBranch(child.children), ) }) -export const toTree = (root: MockScene): Tree => - root.children.map((obj) => - treeObjectFactory( - lowerCaseFirstLetter(obj.type), - { ...obj.__r3f.memoizedProps }, - toTreeBranch([...(obj.children as MockSceneChild[]), ...(obj.__r3f.objects as MockSceneChild[])]), - ), - ) +export const toTree = (root: MockInstance): Tree => toTreeBranch(root.children) diff --git a/packages/test-renderer/src/index.tsx b/packages/test-renderer/src/index.tsx index 38e77c9ca5..8ffe9bd6dd 100644 --- a/packages/test-renderer/src/index.tsx +++ b/packages/test-renderer/src/index.tsx @@ -11,7 +11,7 @@ import { createCanvas } from './createTestCanvas' import { createWebGLContext } from './createWebGLContext' import { createEventFirer } from './fireEvent' -import type { MockScene } from './types/internal' +import type { MockInstance } from './types/internal' import type { CreateOptions, Renderer, Act } from './types/public' import { wrapFiber } from './createTestInstance' @@ -46,67 +46,45 @@ const create = async (element: React.ReactNode, options?: Partial }, }) - const _fiber = canvas + const _root = createRoot(canvas).configure({ frameloop: 'never', ...options, events: undefined }) + const _store = mockRoots.get(canvas)!.store - const _root = createRoot(_fiber).configure({ frameloop: 'never', ...options, events: undefined }) - - let scene: MockScene = null! - - await act(async () => { - scene = _root.render(element).getState().scene as unknown as MockScene - }) - - const _store = mockRoots.get(_fiber)!.store + await act(async () => _root.render(element)) + const _scene = (_store.getState().scene as any).__r3f as MockInstance return { - scene: wrapFiber(scene), - unmount: async () => { + scene: wrapFiber(_scene), + async unmount() { await act(async () => { _root.unmount() }) }, - getInstance: () => { - // this is our root - const fiber = mockRoots.get(_fiber)?.fiber - const current = fiber?.current.child.child - if (current) { - const root = { - /** - * we wrap our child in a Provider component - * and context.Provider, so do a little - * artificial dive to get round this and - * pass context.Provider as if it was the - * actual react root - */ - current, - } + getInstance() { + // Bail if canvas is unmounted + if (!mockRoots.has(canvas)) return null - /** - * so this actually returns the instance - * the user has passed through as a Fiber - */ - return reconciler.getPublicRootInstance(root) - } else { - return null - } + // Traverse fiber nodes for R3F root + let root = { current: mockRoots.get(canvas)!.fiber.current } + while (root.current.stateNode !== _scene) root.current = root.current.child + + // Return R3F instance from root + return reconciler.getPublicRootInstance(root) }, - update: async (newElement: React.ReactNode) => { - const fiber = mockRoots.get(_fiber)?.fiber - if (fiber) { - await act(async () => { - reconciler.updateContainer(newElement, fiber, null, () => null) - }) - } - return + async update(newElement: React.ReactNode) { + if (!mockRoots.has(canvas)) return console.warn('RTTR: attempted to update an unmounted root!') + + await act(async () => { + _root.render(newElement) + }) }, - toTree: () => { - return toTree(scene) + toTree() { + return toTree(_scene) }, - toGraph: () => { - return toGraph(scene) + toGraph() { + return toGraph(_scene) }, fireEvent: createEventFirer(act, _store), - advanceFrames: async (frames: number, delta: number | number[] = 1) => { + async advanceFrames(frames: number, delta: number | number[] = 1) { const state = _store.getState() const storeSubscribers = state.internal.subscribers diff --git a/packages/test-renderer/src/types/internal.ts b/packages/test-renderer/src/types/internal.ts index d4b54d936b..debaa16d52 100644 --- a/packages/test-renderer/src/types/internal.ts +++ b/packages/test-renderer/src/types/internal.ts @@ -1,24 +1,13 @@ -import * as THREE from 'three' import { UseBoundStore } from 'zustand' - -import type { BaseInstance, LocalState, RootState } from '@react-three/fiber' +import type { Instance, RootState } from '@react-three/fiber' export type MockUseStoreState = UseBoundStore -export interface MockInstance extends Omit { - __r3f: Omit & { - root: MockUseStoreState - objects: MockSceneChild[] - parent: MockInstance - } -} - -export interface MockSceneChild extends Omit { - children: MockSceneChild[] -} - -export interface MockScene extends Omit, Pick { - children: MockSceneChild[] +export interface MockInstance extends Omit { + root: MockUseStoreState + parent: MockInstance + children: MockInstance[] + object: O } export type CreateCanvasParameters = {