From 40d36bb84205e7af16e523e236795a05c85a53d0 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 27 Aug 2022 10:20:00 -0500 Subject: [PATCH 01/58] refactor: use instance descriptors --- packages/fiber/src/core/events.ts | 12 +- packages/fiber/src/core/renderer.ts | 465 ++++++++---------- packages/fiber/src/core/store.ts | 19 +- packages/fiber/src/core/utils.ts | 278 +++++------ packages/fiber/src/index.tsx | 2 +- packages/fiber/src/native.tsx | 2 +- packages/fiber/tests/core/renderer.test.tsx | 26 + .../src/__tests__/RTTR.core.test.tsx | 70 +-- .../src/__tests__/RTTR.events.test.tsx | 7 +- .../__snapshots__/RTTR.core.test.tsx.snap | 436 +++++++++++++++- .../test-renderer/src/createTestInstance.ts | 46 +- packages/test-renderer/src/helpers/graph.ts | 6 +- packages/test-renderer/src/helpers/tree.ts | 21 +- packages/test-renderer/src/index.tsx | 76 +-- packages/test-renderer/src/types/internal.ts | 23 +- 15 files changed, 880 insertions(+), 609 deletions(-) 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/renderer.ts b/packages/fiber/src/core/renderer.ts index ce0bafeb17..2901c0b90d 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, prepare } 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: unknown & { __r3f: Instance } 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,238 +42,187 @@ 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, ...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`, - ) - } + ): HostConfig['instance'] { + const name = `${type[0].toUpperCase()}${type.slice(1)}` + const target = catalogue[name] - // 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 }, - }) - } + 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!`) + + // 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!') + + object ??= new target(...args) + + const instance = prepare(object, root) + instance.type = type + instance.props = { ...props, args } // 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' + if (instance.props.attach === undefined) { + if (instance.object instanceof THREE.BufferGeometry) instance.props.attach = 'geometry' + else if (instance.object instanceof THREE.Material) instance.props.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) + applyProps(instance.object, 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) + + if (child.props.attach) { + attach(parent, child) + } else if (parent.object instanceof THREE.Object3D && child.object instanceof THREE.Object3D) { + parent.object.add(child.object) } + + invalidateInstance(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 (!added) parentInstance.__r3f?.objects.push(child) - if (!child.__r3f) prepare(child, {}) - child.__r3f.parent = parentInstance - updateInstance(child) - invalidateInstance(child) + if (!child || !beforeChild) return + + child.parent = parent + parent.children.splice(parent.children.indexOf(beforeChild), 0, child) + + if (child.props.attach) { + attach(parent, child) + } else if ( + parent.object instanceof THREE.Object3D && + child.object instanceof THREE.Object3D && + beforeChild.object instanceof THREE.Object3D + ) { + child.object.parent = parent.object + parent.object.children.splice(parent.object.children.indexOf(beforeChild.object), 0, child.object) + child.object.dispatchEvent({ type: 'added' }) } + + invalidateInstance(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 instanceof THREE.Object3D && parent.object instanceof THREE.Object3D) { + 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 as unknown as any).dispose + if (typeof dispose === 'function') { scheduleCallback(idlePriority, () => { try { - child.dispose() + dispose() } catch (e) { /* ... */ } }) } - - invalidateInstance(parentInstance) } + + if (dispose === undefined) invalidateInstance(child) } 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 + const parent = oldInstance.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) + // Move children to new instance + for (const child of oldInstance.children) { + appendChild(newInstance, child) } - instance.__r3f.objects.forEach((child) => appendChild(newInstance, child)) - instance.__r3f.objects = [] - - removeChild(parent, instance) + // Link up new instance appendChild(parent, newInstance) + removeChild(parent, oldInstance) + oldInstance.children = [] // 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) + if (newInstance.props.raycast !== null && newInstance.object instanceof THREE.Object3D && newInstance.eventCount) { + const rootState = newInstance.root.getState() + rootState.internal.interaction.push(newInstance.object) } // This evil hack switches the react-internal fiber node @@ -284,11 +232,15 @@ 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 } } }) + + invalidateInstance(newInstance) + + return newInstance } // Don't handle text instances, warn on undefined behavior @@ -310,94 +262,93 @@ 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) => { + createInstance, + removeChild, + appendChild, + appendInitialChild: appendChild, + insertBefore, + 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 + const scene = (container.getState().scene as unknown as Instance['object']).__r3f appendChild(scene, child) }, - removeChildFromContainer: (container, child) => { + removeChildFromContainer(container, child) { if (!child) return - removeChild(container.getState().scene as unknown as Instance, child) + removeChild((container.getState().scene as unknown as Instance['object']).__r3f, child) }, - insertInContainerBefore: (container, child, beforeChild) => { + insertInContainerBefore(container, child, beforeChild) { if (!child || !beforeChild) return - insertBefore(container.getState().scene as unknown as Instance, child, beforeChild) + insertBefore((container.getState().scene as unknown as Instance['object']).__r3f, child, beforeChild) }, 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] + + // Throw if an object or literal was passed for args + if (newProps.args && !Array.isArray(newProps.args)) throw new Error('R3F: the args prop must be an array!') + + // 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.eventCount > 0, + commitMount(instance) { + if (instance.props.raycast !== null && instance.object instanceof THREE.Object3D && instance.eventCount) { + const rootState = instance.root.getState() + rootState.internal.interaction.push(instance.object) } }, - getPublicInstance: (instance) => instance!, + getPublicInstance: (instance) => instance?.object!, prepareForCommit: () => null, - preparePortalMount: (container) => prepare(container.getState().scene), + preparePortalMount: (container) => prepare(container.getState().scene, 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 instanceof THREE.Object3D) { + 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 instanceof THREE.Object3D && instance.props.visible !== false) { + instance.object.visible = true + } + invalidateInstance(instance) }, createTextInstance: handleTextInstance, @@ -423,4 +374,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..cd087fb0d7 100644 --- a/packages/fiber/src/core/store.ts +++ b/packages/fiber/src/core/store.ts @@ -1,9 +1,8 @@ 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 { calculateDpr, Camera, isOrthographicCamera, prepare, updateCamera } from './utils' import { FixedStage, Stage } from './stages' // Keys that shouldn't be copied between R3F stores @@ -172,7 +171,7 @@ const createStore = ( invalidate: (state?: RootState, frames?: number) => void, advance: (timestamp: number, runGlobalEffects?: boolean, state?: RootState, frame?: XRFrame) => void, ): UseBoundStore => { - const rootState = create((set, get) => { + const rootStore = create((set, get) => { const position = new THREE.Vector3() const defaultTarget = new THREE.Vector3() const tempTarget = new THREE.Vector3() @@ -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(), @@ -355,13 +354,15 @@ const createStore = ( return rootState }) - const state = rootState.getState() + const state = rootStore.getState() + + prepare(state.scene, rootStore) let oldSize = state.size let oldDpr = state.viewport.dpr let oldCamera = state.camera - rootState.subscribe(() => { - const { camera, size, viewport, gl, set } = rootState.getState() + rootStore.subscribe(() => { + const { camera, size, viewport, gl, set } = rootStore.getState() // Resize camera and renderer on changes to size and pixelratio if (size !== oldSize || viewport.dpr !== oldDpr) { @@ -382,10 +383,10 @@ const createStore = ( }) // Invalidate on any change - rootState.subscribe((state) => invalidate(state)) + rootStore.subscribe((state) => invalidate(state)) // Return root state - return rootState + return rootStore } export { createStore, context } diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 80e2ce0da5..49c6ddbf75 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -1,9 +1,9 @@ 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' +import { UseBoundStore } from 'zustand' export type Camera = THREE.OrthographicCamera | THREE.PerspectiveCamera export const isOrthographicCamera = (def: Camera): def is THREE.OrthographicCamera => @@ -54,14 +54,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 +69,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 */ @@ -143,221 +135,215 @@ 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, - } +export function prepare(object: T, root: UseBoundStore) { + const instance: Instance = { + root, + type: '', + parent: null, + children: [], + props: { args: [] }, + object: object as unknown as Instance['object'], + eventCount: 0, + handlers: {}, } - return object + ;(object as unknown as Instance['object']).__r3f = instance + + return instance } -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 } +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()! + + // 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 instanceof THREE.Object3D && + 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) 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 && instance.props.raycast !== null) rootState.internal.interaction.push(instance.object) } - // 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..aaf0ab0ead 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,29 @@ describe('renderer', () => { const respectedKeys = privateKeys.filter((key) => overwrittenKeys.includes(key) || state[key] === portalState[key]) expect(respectedKeys).toStrictEqual(privateKeys) }) + + it('should gracefully handle text', async () => { + const warn = console.warn + console.warn = jest.fn() + + // 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( + + + , + ) + }) + + expect(console.warn).toHaveBeenCalled() + console.warn = warn + }) }) 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..ba04dfe235 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 + const root = { current: mockRoots.get(canvas)!.fiber.current } + while (!root.current.child?.stateNode) 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 = { From 5923e1feae20bfcb35284ae4d939898810d987bf Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 27 Aug 2022 17:37:58 -0500 Subject: [PATCH 02/58] fix: remove duplicate args check, align text warning --- packages/fiber/src/core/renderer.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index 2901c0b90d..ed710e6a93 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -245,7 +245,7 @@ function createRenderer(_roots: Map, _getEventPriority?: // Don't handle text instances, warn on undefined behavior const handleTextInstance = () => - console.warn('Text is not allowed in the R3F tree! This could be stray whitespace or characters.') + console.warn('R3F: Text is not allowed in JSX! This could be stray whitespace or characters.') const reconciler = Reconciler< HostConfig['type'], @@ -291,9 +291,6 @@ function createRenderer(_roots: Map, _getEventPriority?: // Reconstruct primitives if object prop changes if (instance.type === 'primitive' && oldProps.object !== newProps.object) return [true] - // Throw if an object or literal was passed for args - if (newProps.args && !Array.isArray(newProps.args)) throw new Error('R3F: the args prop must be an array!') - // Reconstruct elements if args change if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) return [true] From 816d2319dab759455ee09c16378dff6363a47a7c Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 27 Aug 2022 18:05:01 -0500 Subject: [PATCH 03/58] fix: don't overwrite containers in prepare --- packages/fiber/src/core/renderer.ts | 13 +++--------- packages/fiber/src/core/store.ts | 2 +- packages/fiber/src/core/utils.ts | 31 +++++++++++++++++++---------- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index ed710e6a93..f08a62bade 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -79,11 +79,7 @@ function createRenderer(_roots: Map, _getEventPriority?: // 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!') - object ??= new target(...args) - - const instance = prepare(object, root) - instance.type = type - instance.props = { ...props, args } + const instance = prepare(object ?? new target(...args), root, type, { ...props, args }) // Auto-attach geometries and materials if (instance.props.attach === undefined) { @@ -91,6 +87,7 @@ function createRenderer(_roots: Map, _getEventPriority?: else if (instance.object instanceof THREE.Material) instance.props.attach = 'material' } + // Set initial props applyProps(instance.object, props) return instance @@ -322,13 +319,11 @@ function createRenderer(_roots: Map, _getEventPriority?: }, getPublicInstance: (instance) => instance?.object!, prepareForCommit: () => null, - preparePortalMount: (container) => prepare(container.getState().scene, container), + preparePortalMount: (container) => prepare(container.getState().scene, container, '', {}), resetAfterCommit: () => {}, shouldSetTextContent: () => false, clearContainer: () => false, hideInstance(instance) { - if (!instance.object) return - if (instance.props.attach && instance.parent?.object) { detach(instance.parent, instance) } else if (instance.object instanceof THREE.Object3D) { @@ -338,8 +333,6 @@ function createRenderer(_roots: Map, _getEventPriority?: invalidateInstance(instance) }, unhideInstance(instance) { - if (!instance.object) return - if (instance.props.attach && instance.parent?.object) { attach(instance.parent, instance) } else if (instance.object instanceof THREE.Object3D && instance.props.visible !== false) { diff --git a/packages/fiber/src/core/store.ts b/packages/fiber/src/core/store.ts index cd087fb0d7..cd899a6c14 100644 --- a/packages/fiber/src/core/store.ts +++ b/packages/fiber/src/core/store.ts @@ -356,7 +356,7 @@ const createStore = ( const state = rootStore.getState() - prepare(state.scene, rootStore) + prepare(state.scene, rootStore, '', {}) let oldSize = state.size let oldDpr = state.viewport.dpr diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 49c6ddbf75..c56673c03f 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -135,18 +135,27 @@ export function dispose void; type?: string; [key } // Each object in the scene carries a small LocalState descriptor -export function prepare(object: T, root: UseBoundStore) { - const instance: Instance = { - root, - type: '', - parent: null, - children: [], - props: { args: [] }, - object: object as unknown as Instance['object'], - eventCount: 0, - handlers: {}, +export function prepare( + object: T, + root: UseBoundStore, + type: string, + { args = [], ...props }: InstanceProps, +) { + // Create instance descriptor + let instance: Instance | undefined = (object as unknown as Instance['object']).__r3f + if (!instance) { + instance = { + root, + type, + parent: null, + children: [], + props: { ...props, args }, + object: object as unknown as Instance['object'], + eventCount: 0, + handlers: {}, + } + ;(object as unknown as Instance['object']).__r3f = instance } - ;(object as unknown as Instance['object']).__r3f = instance return instance } From cd36cfb252952686747bd6716ad88b11c1d32d0b Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 27 Aug 2022 19:20:16 -0500 Subject: [PATCH 04/58] fix: type applyProps, flatten reconciler --- packages/fiber/src/core/events.ts | 18 +- packages/fiber/src/core/index.tsx | 6 +- packages/fiber/src/core/renderer.ts | 543 ++++++++++++++-------------- packages/fiber/src/core/utils.ts | 4 +- 4 files changed, 283 insertions(+), 288 deletions(-) diff --git a/packages/fiber/src/core/events.ts b/packages/fiber/src/core/events.ts index dd2c07ab0f..4f482f5bc6 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 any).__r3f as Instance)?.handlers[('onPointer' + name) as keyof EventHandlers], + (name) => ((obj as any).__r3f as Instance | undefined)?.handlers[('onPointer' + name) as keyof EventHandlers], ), ) } @@ -239,7 +239,8 @@ export function createEvents(store: UseBoundStore) { let eventObject: THREE.Object3D | null = hit.object // Bubble event up while (eventObject) { - if (((eventObject as any).__r3f as Instance)?.eventCount) intersections.push({ ...hit, eventObject }) + if (((eventObject as any).__r3f as Instance | undefined)?.eventCount) + intersections.push({ ...hit, eventObject }) eventObject = eventObject.parent } } @@ -369,10 +370,10 @@ export function createEvents(store: UseBoundStore) { ) ) { const eventObject = hoveredObj.eventObject - const instance = (eventObject as any).__r3f as Instance - const handlers = instance?.handlers + const instance = (eventObject as any).__r3f as Instance | undefined internal.hovered.delete(makeId(hoveredObj)) if (instance?.eventCount) { + const handlers = instance.handlers // Clear out intersects, they are outdated by now const data = { ...hoveredObj, intersections } handlers.onPointerOut?.(data as ThreeEvent) @@ -434,10 +435,11 @@ export function createEvents(store: UseBoundStore) { handleIntersects(hits, event, delta, (data: ThreeEvent) => { const eventObject = data.eventObject - const instance = (eventObject as any).__r3f as Instance - const handlers = instance?.handlers + const instance = (eventObject as any).__r3f as Instance | undefined + // Check presence of handlers if (!instance?.eventCount) return + const handlers = instance.handlers if (isPointerMove) { // Move event ... @@ -487,7 +489,9 @@ export function createEvents(store: UseBoundStore) { } function pointerMissed(event: MouseEvent, objects: THREE.Object3D[]) { - objects.forEach((object: THREE.Object3D) => ((object as any).__r3f as Instance)?.handlers.onPointerMissed?.(event)) + objects.forEach((object: THREE.Object3D) => + ((object as any).__r3f as Instance | undefined)?.handlers.onPointerMissed?.(event), + ) } return { handlePointer } diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index 0e0605b6dc..bebedf7b05 100644 --- a/packages/fiber/src/core/index.tsx +++ b/packages/fiber/src/core/index.tsx @@ -19,9 +19,9 @@ import { FrameloopLegacy, Frameloop, } from './store' -import { createRenderer, extend, Root } from './renderer' +import { reconciler, extend, Root } from './renderer' import { createLoop, addEffect, addAfterEffect, addTail } from './loop' -import { getEventPriority, EventManager, ComputeFunction } from './events' +import { EventManager, ComputeFunction } from './events' import { is, dispose, @@ -31,6 +31,7 @@ import { useIsomorphicLayoutEffect, Camera, updateCamera, + applyProps, } from './utils' import { useStore } from './hooks' import { Stage, Lifecycle, Stages } from './stages' @@ -38,7 +39,6 @@ import { OffscreenCanvas } from 'three' const roots = new Map() const { invalidate, advance } = createLoop(roots) -const { reconciler, applyProps } = createRenderer(roots, getEventPriority) const shallowLoose = { objects: 'shallow', strict: false } as EquConfig type Properties = Pick any ? never : K }[keyof T]> diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index f08a62bade..3a10ed4827 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -2,10 +2,9 @@ import * as THREE from 'three' 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, diffProps, applyProps, invalidateInstance, attach, detach, prepare } from './utils' import { RootState } from './store' -import { EventHandlers, removeInteractivity } from './events' +import { EventHandlers, removeInteractivity, getEventPriority } from './events' export type Root = { fiber: Reconciler.FiberRoot; store: UseBoundStore } @@ -28,7 +27,7 @@ export interface Instance { parent: Instance | null children: Instance[] props: InstanceProps - object: unknown & { __r3f: Instance } + object: unknown & { __r3f?: Instance } eventCount: number handlers: Partial attach?: AttachType @@ -60,308 +59,300 @@ interface Catalogue { const catalogue: Catalogue = {} const extend = (objects: object): void => void Object.assign(catalogue, objects) -function createRenderer(_roots: Map, _getEventPriority?: () => any) { - function createInstance( - type: string, - { args = [], object, ...props }: InstanceProps, - root: UseBoundStore, - ): HostConfig['instance'] { - const name = `${type[0].toUpperCase()}${type.slice(1)}` - const target = catalogue[name] +function createInstance( + type: string, + { args = [], object, ...props }: InstanceProps, + root: UseBoundStore, +): 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' && !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!`) + if (type === 'primitive' && !object) throw new Error(`R3F: Primitives without 'object' are invalid!`) - // 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!') + // 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!') - const instance = prepare(object ?? new target(...args), root, type, { ...props, args }) + const instance = prepare(object ?? new target(...args), root, type, { ...props, args }) - // Auto-attach geometries and materials - if (instance.props.attach === undefined) { - if (instance.object instanceof THREE.BufferGeometry) instance.props.attach = 'geometry' - else if (instance.object instanceof THREE.Material) instance.props.attach = 'material' - } - - // Set initial props - applyProps(instance.object, props) - - return instance + // Auto-attach geometries and materials + if (instance.props.attach === undefined) { + if (instance.object instanceof THREE.BufferGeometry) instance.props.attach = 'geometry' + else if (instance.object instanceof THREE.Material) instance.props.attach = 'material' } - function appendChild(parent: HostConfig['instance'], child: HostConfig['instance'] | HostConfig['textInstance']) { - if (!child) return - - child.parent = parent - parent.children.push(child) - - if (child.props.attach) { - attach(parent, child) - } else if (parent.object instanceof THREE.Object3D && child.object instanceof THREE.Object3D) { - parent.object.add(child.object) - } + // Set initial props + applyProps(instance.object, props) - invalidateInstance(child) - } + return instance +} - function insertBefore( - parent: HostConfig['instance'], - child: HostConfig['instance'] | HostConfig['textInstance'], - beforeChild: HostConfig['instance'] | HostConfig['textInstance'], - ) { - if (!child || !beforeChild) return +function appendChild(parent: HostConfig['instance'], child: HostConfig['instance'] | HostConfig['textInstance']) { + if (!child) return - child.parent = parent - parent.children.splice(parent.children.indexOf(beforeChild), 0, child) - - if (child.props.attach) { - attach(parent, child) - } else if ( - parent.object instanceof THREE.Object3D && - child.object instanceof THREE.Object3D && - beforeChild.object instanceof THREE.Object3D - ) { - child.object.parent = parent.object - parent.object.children.splice(parent.object.children.indexOf(beforeChild.object), 0, child.object) - child.object.dispatchEvent({ type: 'added' }) - } + child.parent = parent + parent.children.push(child) - invalidateInstance(child) + if (child.props.attach) { + attach(parent, child) + } else if (parent.object instanceof THREE.Object3D && child.object instanceof THREE.Object3D) { + parent.object.add(child.object) } - function removeRecursive( - children: HostConfig['instance'][], - parent: HostConfig['instance'], - dispose: boolean = false, - ) { - for (const child of children) { - removeChild(parent, child, dispose) - } - } + invalidateInstance(child) +} - function removeChild( - parent: HostConfig['instance'], - child: HostConfig['instance'] | HostConfig['textInstance'], - dispose?: boolean, +function insertBefore( + parent: HostConfig['instance'], + child: HostConfig['instance'] | HostConfig['textInstance'], + beforeChild: HostConfig['instance'] | HostConfig['textInstance'], +) { + if (!child || !beforeChild) return + + child.parent = parent + parent.children.splice(parent.children.indexOf(beforeChild), 0, child) + + if (child.props.attach) { + attach(parent, child) + } else if ( + parent.object instanceof THREE.Object3D && + child.object instanceof THREE.Object3D && + beforeChild.object instanceof THREE.Object3D ) { - if (!child) return - - child.parent = null - const childIndex = parent.children.indexOf(child) - if (childIndex !== -1) parent.children.splice(childIndex, 1) - - if (child.props.attach) { - detach(parent, child) - } else if (child.object instanceof THREE.Object3D && parent.object instanceof THREE.Object3D) { - parent.object.remove(child.object) - removeInteractivity(child.root, child.object as unknown as THREE.Object3D) - } + child.object.parent = parent.object + parent.object.children.splice(parent.object.children.indexOf(beforeChild.object), 0, child.object) + child.object.dispatchEvent({ type: 'added' }) + } - // 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 as unknown as any).dispose - if (typeof dispose === 'function') { - scheduleCallback(idlePriority, () => { - try { - dispose() - } catch (e) { - /* ... */ - } - }) - } - } + invalidateInstance(child) +} - if (dispose === undefined) invalidateInstance(child) +function removeRecursive(children: HostConfig['instance'][], parent: HostConfig['instance'], dispose: boolean = false) { + for (const child of children) { + removeChild(parent, child, dispose) } +} - function switchInstance( - oldInstance: HostConfig['instance'], - type: HostConfig['type'], - props: HostConfig['props'], - fiber: Reconciler.Fiber, - ) { - const parent = oldInstance.parent - if (!parent) return - - // Create a new instance - const newInstance = createInstance(type, props, oldInstance.root) +function removeChild( + parent: HostConfig['instance'], + child: HostConfig['instance'] | HostConfig['textInstance'], + dispose?: boolean, +) { + if (!child) return + + child.parent = null + const childIndex = parent.children.indexOf(child) + if (childIndex !== -1) parent.children.splice(childIndex, 1) + + if (child.props.attach) { + detach(parent, child) + } else if (child.object instanceof THREE.Object3D && parent.object instanceof THREE.Object3D) { + parent.object.remove(child.object) + removeInteractivity(child.root, child.object as unknown as THREE.Object3D) + } - // Move children to new instance - for (const child of oldInstance.children) { - appendChild(newInstance, child) + // 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 as unknown as any).dispose + if (typeof dispose === 'function') { + scheduleCallback(idlePriority, () => { + try { + dispose() + } catch (e) { + /* ... */ + } + }) } + } - // Link up new instance - appendChild(parent, newInstance) - removeChild(parent, oldInstance) - oldInstance.children = [] - - // Re-bind event handlers - if (newInstance.props.raycast !== null && newInstance.object instanceof THREE.Object3D && newInstance.eventCount) { - const rootState = newInstance.root.getState() - rootState.internal.interaction.push(newInstance.object) - } + if (dispose === undefined) invalidateInstance(child) +} - // This evil hack switches the react-internal fiber node - // https://github.com/facebook/react/issues/14983 - // https://github.com/facebook/react/pull/15021 - ;[fiber, fiber.alternate].forEach((fiber) => { - if (fiber !== null) { - fiber.stateNode = newInstance - if (fiber.ref) { - if (typeof fiber.ref === 'function') (fiber as unknown as any).ref(newInstance.object) - else (fiber.ref as Reconciler.RefObject).current = newInstance.object - } - } - }) +function switchInstance( + oldInstance: HostConfig['instance'], + type: HostConfig['type'], + props: HostConfig['props'], + fiber: Reconciler.Fiber, +) { + const parent = oldInstance.parent + if (!parent) return + + // Create a new instance + const newInstance = createInstance(type, props, oldInstance.root) + + // Move children to new instance + for (const child of oldInstance.children) { + appendChild(newInstance, child) + } - invalidateInstance(newInstance) + // Link up new instance + appendChild(parent, newInstance) + removeChild(parent, oldInstance) + oldInstance.children = [] - return newInstance + // Re-bind event handlers + if (newInstance.props.raycast !== null && newInstance.object instanceof THREE.Object3D && newInstance.eventCount) { + const rootState = newInstance.root.getState() + rootState.internal.interaction.push(newInstance.object) } - // Don't handle text instances, warn on undefined behavior - const handleTextInstance = () => - console.warn('R3F: Text is not allowed in JSX! This could be stray whitespace or characters.') - - const reconciler = Reconciler< - HostConfig['type'], - HostConfig['props'], - HostConfig['container'], - HostConfig['instance'], - HostConfig['textInstance'], - HostConfig['suspenseInstance'], - HostConfig['hydratableInstance'], - HostConfig['publicInstance'], - HostConfig['hostContext'], - HostConfig['updatePayload'], - HostConfig['childSet'], - HostConfig['timeoutHandle'], - HostConfig['noTimeout'] - >({ - supportsMutation: true, - isPrimaryRenderer: false, - supportsPersistence: false, - supportsHydration: false, - noTimeout: -1, - createInstance, - removeChild, - appendChild, - appendInitialChild: appendChild, - insertBefore, - appendChildToContainer(container, child) { - if (!child) return - const scene = (container.getState().scene as unknown as Instance['object']).__r3f - appendChild(scene, child) - }, - removeChildFromContainer(container, child) { - if (!child) return - removeChild((container.getState().scene as unknown as Instance['object']).__r3f, child) - }, - insertInContainerBefore(container, child, beforeChild) { - if (!child || !beforeChild) return - insertBefore((container.getState().scene as unknown as Instance['object']).__r3f, child, beforeChild) - }, - getRootHostContext: () => null, - getChildHostContext: (parentHostContext) => parentHostContext, - prepareUpdate(instance, _type, oldProps, newProps) { - // 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, diff, type, _oldProps, newProps, fiber) { - const [reconstruct, changedProps] = diff! - - // Reconstruct when args or instance.eventCount > 0, - commitMount(instance) { - if (instance.props.raycast !== null && instance.object instanceof THREE.Object3D && instance.eventCount) { - const rootState = instance.root.getState() - rootState.internal.interaction.push(instance.object) - } - }, - getPublicInstance: (instance) => instance?.object!, - prepareForCommit: () => null, - preparePortalMount: (container) => prepare(container.getState().scene, container, '', {}), - resetAfterCommit: () => {}, - shouldSetTextContent: () => false, - clearContainer: () => false, - hideInstance(instance) { - if (instance.props.attach && instance.parent?.object) { - detach(instance.parent, instance) - } else if (instance.object instanceof THREE.Object3D) { - instance.object.visible = false + // This evil hack switches the react-internal fiber node + // https://github.com/facebook/react/issues/14983 + // https://github.com/facebook/react/pull/15021 + ;[fiber, fiber.alternate].forEach((fiber) => { + if (fiber !== null) { + fiber.stateNode = newInstance + if (fiber.ref) { + if (typeof fiber.ref === 'function') (fiber as unknown as any).ref(newInstance.object) + else (fiber.ref as Reconciler.RefObject).current = newInstance.object } - - invalidateInstance(instance) - }, - unhideInstance(instance) { - if (instance.props.attach && instance.parent?.object) { - attach(instance.parent, instance) - } else if (instance.object instanceof THREE.Object3D && instance.props.visible !== false) { - instance.object.visible = true - } - - invalidateInstance(instance) - }, - createTextInstance: handleTextInstance, - hideTextInstance: handleTextInstance, - unhideTextInstance: handleTextInstance, - // https://github.com/pmndrs/react-three-fiber/pull/2360#discussion_r916356874 - // @ts-ignore - getCurrentEventPriority: () => (_getEventPriority ? _getEventPriority() : DefaultEventPriority), - beforeActiveInstanceBlur: () => {}, - afterActiveInstanceBlur: () => {}, - detachDeletedInstance: () => {}, - now: - typeof performance !== 'undefined' && is.fun(performance.now) - ? performance.now - : is.fun(Date.now) - ? Date.now - : () => 0, - // https://github.com/pmndrs/react-three-fiber/pull/2360#discussion_r920883503 - scheduleTimeout: (is.fun(setTimeout) ? setTimeout : undefined) as any, - cancelTimeout: (is.fun(clearTimeout) ? clearTimeout : undefined) as any, + } }) - return { reconciler, applyProps } + invalidateInstance(newInstance) + + return newInstance } -export { createRenderer, extend } +// Don't handle text instances, warn on undefined behavior +const handleTextInstance = () => + console.warn('R3F: Text is not allowed in JSX! This could be stray whitespace or characters.') + +const reconciler = Reconciler< + HostConfig['type'], + HostConfig['props'], + HostConfig['container'], + HostConfig['instance'], + HostConfig['textInstance'], + HostConfig['suspenseInstance'], + HostConfig['hydratableInstance'], + HostConfig['publicInstance'], + HostConfig['hostContext'], + HostConfig['updatePayload'], + HostConfig['childSet'], + HostConfig['timeoutHandle'], + HostConfig['noTimeout'] +>({ + supportsMutation: true, + isPrimaryRenderer: false, + supportsPersistence: false, + supportsHydration: false, + noTimeout: -1, + createInstance, + removeChild, + appendChild, + appendInitialChild: appendChild, + insertBefore, + appendChildToContainer(container, child) { + if (!child) return + const scene = (container.getState().scene as unknown as Instance['object']).__r3f! + appendChild(scene, child) + }, + removeChildFromContainer(container, child) { + if (!child) return + removeChild((container.getState().scene as unknown as Instance['object']).__r3f!, child) + }, + insertInContainerBefore(container, child, beforeChild) { + if (!child || !beforeChild) return + insertBefore((container.getState().scene as unknown as Instance['object']).__r3f!, child, beforeChild) + }, + getRootHostContext: () => null, + getChildHostContext: (parentHostContext) => parentHostContext, + prepareUpdate(instance, _type, oldProps, newProps) { + // 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, diff, type, _oldProps, newProps, fiber) { + const [reconstruct, changedProps] = diff! + + // Reconstruct when args or instance.eventCount > 0, + commitMount(instance) { + if (instance.props.raycast !== null && instance.object instanceof THREE.Object3D && instance.eventCount) { + const rootState = instance.root.getState() + rootState.internal.interaction.push(instance.object) + } + }, + getPublicInstance: (instance) => instance?.object!, + prepareForCommit: () => null, + preparePortalMount: (container) => prepare(container.getState().scene, container, '', {}), + resetAfterCommit: () => {}, + shouldSetTextContent: () => false, + clearContainer: () => false, + hideInstance(instance) { + if (instance.props.attach && instance.parent?.object) { + detach(instance.parent, instance) + } else if (instance.object instanceof THREE.Object3D) { + instance.object.visible = false + } + + invalidateInstance(instance) + }, + unhideInstance(instance) { + if (instance.props.attach && instance.parent?.object) { + attach(instance.parent, instance) + } else if (instance.object instanceof THREE.Object3D && instance.props.visible !== false) { + instance.object.visible = true + } + + invalidateInstance(instance) + }, + createTextInstance: handleTextInstance, + hideTextInstance: handleTextInstance, + unhideTextInstance: handleTextInstance, + // https://github.com/pmndrs/react-three-fiber/pull/2360#discussion_r916356874 + // @ts-ignore + getCurrentEventPriority: () => getEventPriority(), + beforeActiveInstanceBlur: () => {}, + afterActiveInstanceBlur: () => {}, + detachDeletedInstance: () => {}, + now: + typeof performance !== 'undefined' && is.fun(performance.now) + ? performance.now + : is.fun(Date.now) + ? Date.now + : () => 0, + // https://github.com/pmndrs/react-three-fiber/pull/2360#discussion_r920883503 + scheduleTimeout: (is.fun(setTimeout) ? setTimeout : undefined) as any, + cancelTimeout: (is.fun(clearTimeout) ? clearTimeout : undefined) as any, +}) + +export { extend, reconciler } diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index c56673c03f..04c95ff850 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -250,8 +250,8 @@ export function diffProps(newProps: InstanceProps, oldProps: InstanceProps, remo } // This function applies a set of changes to the instance -export function applyProps(object: any, props: any) { - const instance = object.__r3f as Instance | undefined +export function applyProps(object: Instance['object'], props: InstanceProps) { + const instance = object.__r3f const rootState = instance?.root.getState() const prevHandlers = instance?.eventCount From 0fdf940c40bf0ae5caecaec708f39dbb590bdcdd Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 27 Aug 2022 19:38:44 -0500 Subject: [PATCH 05/58] fix: don't recursively remove on swap --- packages/fiber/src/core/renderer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index 3a10ed4827..16e43f32ce 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -140,6 +140,7 @@ function removeRecursive(children: HostConfig['instance'][], parent: HostConfig[ function removeChild( parent: HostConfig['instance'], child: HostConfig['instance'] | HostConfig['textInstance'], + recursive = true, dispose?: boolean, ) { if (!child) return @@ -169,7 +170,7 @@ function removeChild( // Remove nested child objects. Primitives should not have objects and children that are // attached to them declaratively ... - if (!isPrimitive) removeRecursive(child.children, child, shouldDispose) + if (!isPrimitive && recursive) removeRecursive(child.children, child, shouldDispose) // Dispose object whenever the reconciler feels like it if (child.type !== 'scene' && shouldDispose) { @@ -207,7 +208,7 @@ function switchInstance( // Link up new instance appendChild(parent, newInstance) - removeChild(parent, oldInstance) + removeChild(parent, oldInstance, false) oldInstance.children = [] // Re-bind event handlers From dc4e3b1b0e2535b3faa1b14e3bb29eca563e8d95 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 28 Aug 2022 13:50:31 -0500 Subject: [PATCH 06/58] refactor(types): automate ThreeElements --- example/src/demos/Lines.tsx | 2 +- example/src/demos/Pointcloud.tsx | 2 +- example/typings/global.d.ts | 7 +- packages/fiber/src/core/events.ts | 2 +- packages/fiber/src/core/index.tsx | 10 +- packages/fiber/src/core/renderer.ts | 40 +- packages/fiber/src/core/types.ts | 87 +++++ packages/fiber/src/core/utils.ts | 8 +- packages/fiber/src/index.tsx | 15 +- packages/fiber/src/native.tsx | 15 +- packages/fiber/src/native/Canvas.tsx | 2 +- packages/fiber/src/three-types.ts | 404 -------------------- packages/fiber/src/web/Canvas.tsx | 2 +- packages/fiber/tests/core/renderer.test.tsx | 19 +- packages/fiber/tests/setupTests.ts | 2 +- packages/test-renderer/src/index.tsx | 2 +- 16 files changed, 138 insertions(+), 481 deletions(-) create mode 100644 packages/fiber/src/core/types.ts delete mode 100644 packages/fiber/src/three-types.ts diff --git a/example/src/demos/Lines.tsx b/example/src/demos/Lines.tsx index 600d88f7d0..98c8e07582 100644 --- a/example/src/demos/Lines.tsx +++ b/example/src/demos/Lines.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect, useState, useCallback, useContext, useMemo } from 'react' -import { extend, Canvas, useThree, ReactThreeFiber } from '@react-three/fiber' +import { extend, Canvas, useThree } from '@react-three/fiber' import { OrbitControls } from 'three-stdlib' extend({ OrbitControls }) diff --git a/example/src/demos/Pointcloud.tsx b/example/src/demos/Pointcloud.tsx index ef8927cb6a..57870cf450 100644 --- a/example/src/demos/Pointcloud.tsx +++ b/example/src/demos/Pointcloud.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect, useState, useCallback, useContext, useMemo } from 'react' -import { extend, Canvas, useThree, ReactThreeFiber } from '@react-three/fiber' +import { extend, Canvas, useThree } from '@react-three/fiber' import * as THREE from 'three' import { OrbitControls } from 'three-stdlib' extend({ OrbitControls }) diff --git a/example/typings/global.d.ts b/example/typings/global.d.ts index 38d720c32c..5aac79358c 100644 --- a/example/typings/global.d.ts +++ b/example/typings/global.d.ts @@ -1,11 +1,10 @@ -import { ReactThreeFiber } from '@react-three/fiber' +import { Node } from '@react-three/fiber' import { OrbitControls } from 'three-stdlib' - import { DotMaterial } from '../src/demos/Pointcloud' declare module '@react-three/fiber' { interface ThreeElements { - orbitControls: ReactThreeFiber.Node - dotMaterial: ReactThreeFiber.MaterialNode + orbitControls: Node + dotMaterial: Node } } diff --git a/packages/fiber/src/core/events.ts b/packages/fiber/src/core/events.ts index 4f482f5bc6..da719fadac 100644 --- a/packages/fiber/src/core/events.ts +++ b/packages/fiber/src/core/events.ts @@ -2,7 +2,7 @@ import * as THREE from 'three' import { ContinuousEventPriority, DiscreteEventPriority, DefaultEventPriority } from 'react-reconciler/constants' import { getRootState } from './utils' import type { UseBoundStore } from 'zustand' -import type { Instance } from './renderer' +import type { Instance } from './types' import type { RootState } from './store' export interface Intersection extends THREE.Intersection { diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index bebedf7b05..6d62c54a41 100644 --- a/packages/fiber/src/core/index.tsx +++ b/packages/fiber/src/core/index.tsx @@ -3,7 +3,6 @@ import * as React from 'react' import { ConcurrentRoot } from 'react-reconciler/constants' import create, { StoreApi, UseBoundStore } from 'zustand' -import * as ReactThreeFiber from '../three-types' import { Renderer, createStore, @@ -36,6 +35,7 @@ import { import { useStore } from './hooks' import { Stage, Lifecycle, Stages } from './stages' import { OffscreenCanvas } from 'three' +import { InstanceProps } from './types' const roots = new Map() const { invalidate, advance } = createLoop(roots) @@ -87,11 +87,9 @@ export type RenderProps = { /** A `THREE.Camera` instance or props that go into the default camera */ camera?: ( | Camera - | Partial< - ReactThreeFiber.Object3DNode & - ReactThreeFiber.Object3DNode & - ReactThreeFiber.Object3DNode - > + | InstanceProps + | InstanceProps + | InstanceProps ) & { /** Flags the camera as manual, putting projection into your own hands */ manual?: boolean diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index 16e43f32ce..253a93aa83 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -4,34 +4,12 @@ import Reconciler from 'react-reconciler' import { unstable_IdlePriority as idlePriority, unstable_scheduleCallback as scheduleCallback } from 'scheduler' import { is, diffProps, applyProps, invalidateInstance, attach, detach, prepare } from './utils' import { RootState } from './store' -import { EventHandlers, removeInteractivity, getEventPriority } from './events' +import { removeInteractivity, getEventPriority } from './events' +import type { InstanceProps, Instance, Catalogue } from './types' -export type Root = { fiber: Reconciler.FiberRoot; store: UseBoundStore } - -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 - type: string - parent: Instance | null - children: Instance[] - props: InstanceProps - object: unknown & { __r3f?: Instance } - eventCount: number - handlers: Partial - attach?: AttachType - previousAttach?: any +export interface Root { + fiber: Reconciler.FiberRoot + store: UseBoundStore } interface HostConfig { @@ -50,14 +28,8 @@ interface HostConfig { noTimeout: -1 } -interface Catalogue { - [name: string]: { - new (...args: any): any - } -} - const catalogue: Catalogue = {} -const extend = (objects: object): void => void Object.assign(catalogue, objects) +const extend = (objects: Partial): void => void Object.assign(catalogue, objects) function createInstance( type: string, diff --git a/packages/fiber/src/core/types.ts b/packages/fiber/src/core/types.ts new file mode 100644 index 0000000000..8333395c55 --- /dev/null +++ b/packages/fiber/src/core/types.ts @@ -0,0 +1,87 @@ +import type * as THREE from 'three' +import type { UseBoundStore } from 'zustand' +import type { EventHandlers } from './events' +import type { RootState } from './store' + +export type AttachFnType = (parent: any, self: O) => () => void +export type AttachType = string | AttachFnType + +type Mutable = { [K in keyof T]: T[K] | Readonly } +type NonFunctionKeys = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T] +type WithoutFunctions = Pick> +type Overwrite = Omit> & O +type ConstructorRepresentation = new (...args: any[]) => any +type Args = T extends ConstructorRepresentation ? ConstructorParameters : any[] + +interface MathRepresentation { + set(...args: any[]): any +} +interface VectorRepresentation extends MathRepresentation { + setScalar(s: number): any +} +type MathProps = { + [K in keyof T]: T[K] extends infer M + ? M extends THREE.Color + ? ConstructorParameters | THREE.ColorRepresentation + : M extends MathRepresentation + ? M extends VectorRepresentation + ? M | Parameters | Parameters[0] + : M | Parameters + : {} + : never +} + +interface RaycastableRepresentation { + raycast(raycaster: THREE.Raycaster, intersects: THREE.Intersection[]): void +} +type EventProps = T extends RaycastableRepresentation ? EventHandlers : {} + +export type InstanceProps = { + args?: Args + object?: T + visible?: boolean + dispose?: null + attach?: AttachType +} & Partial & EventProps

> + +export interface Instance { + root: UseBoundStore + type: string + parent: Instance | null + children: Instance[] + props: InstanceProps + object: O & { __r3f?: Instance } + eventCount: number + handlers: Partial + attach?: AttachType + previousAttach?: any +} + +interface ReactProps { + children?: React.ReactNode + ref?: React.Ref + key?: React.Key +} + +export type Node = Mutable< + Overwrite>, InstanceProps & ReactProps> +> + +type ThreeExports = typeof THREE +export type ThreeElements = { + [K in keyof ThreeExports as Uncapitalize]: ThreeExports[K] extends ConstructorRepresentation + ? Omit, 'object'> + : never +} + +export interface Catalogue { + [name: string]: ConstructorRepresentation +} + +declare global { + namespace JSX { + interface IntrinsicElements extends ThreeElements { + primitive: Omit, 'args'> + } + } +} diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 04c95ff850..b0a72fcbd5 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -1,9 +1,9 @@ import * as THREE from 'three' import * as React from 'react' -import { EventHandlers } from './events' -import { Instance, InstanceProps } from './renderer' -import { Dpr, RootState, Size } from './store' -import { UseBoundStore } from 'zustand' +import type { UseBoundStore } from 'zustand' +import type { EventHandlers } from './events' +import type { Dpr, RootState, Size } from './store' +import type { Instance, InstanceProps } from './types' export type Camera = THREE.OrthographicCamera | THREE.PerspectiveCamera export const isOrthographicCamera = (def: Camera): def is THREE.OrthographicCamera => diff --git a/packages/fiber/src/index.tsx b/packages/fiber/src/index.tsx index 42388801c1..f245bb1d09 100644 --- a/packages/fiber/src/index.tsx +++ b/packages/fiber/src/index.tsx @@ -1,7 +1,14 @@ -export * from './three-types' -import * as ReactThreeFiber from './three-types' -export { ReactThreeFiber } -export type { Instance } from './core/renderer' +import { Node, ThreeElements } from './core/types' + +declare global { + namespace JSX { + interface IntrinsicElements extends ThreeElements { + primitive: Omit, 'args'> + } + } +} + +export type { AttachFnType, AttachType, Node, ThreeElements, Catalogue, Instance, InstanceProps } from './core/types' export type { Intersection, Subscription, diff --git a/packages/fiber/src/native.tsx b/packages/fiber/src/native.tsx index 014371c76b..346193b7c3 100644 --- a/packages/fiber/src/native.tsx +++ b/packages/fiber/src/native.tsx @@ -1,7 +1,14 @@ -export * from './three-types' -import * as ReactThreeFiber from './three-types' -export { ReactThreeFiber } -export type { Instance } from './core/renderer' +import { Node, ThreeElements } from './core/types' + +declare global { + namespace JSX { + interface IntrinsicElements extends ThreeElements { + primitive: Omit, 'args'> + } + } +} + +export type { AttachFnType, AttachType, Node, ThreeElements, Catalogue, Instance, InstanceProps } from './core/types' export type { Intersection, Subscription, diff --git a/packages/fiber/src/native/Canvas.tsx b/packages/fiber/src/native/Canvas.tsx index 62685a010f..8a7d26100a 100644 --- a/packages/fiber/src/native/Canvas.tsx +++ b/packages/fiber/src/native/Canvas.tsx @@ -43,7 +43,7 @@ export const Canvas = /*#__PURE__*/ React.forwardRef( // Create a known catalogue of Threejs-native elements // This will include the entire THREE namespace by default, users can extend // their own elements by using the createRoot API instead - React.useMemo(() => extend(THREE), []) + React.useMemo(() => extend(THREE as any), []) const [{ width, height, top, left }, setSize] = React.useState({ width: 0, height: 0, top: 0, left: 0 }) const [canvas, setCanvas] = React.useState(null) diff --git a/packages/fiber/src/three-types.ts b/packages/fiber/src/three-types.ts deleted file mode 100644 index 4eb84bdc46..0000000000 --- a/packages/fiber/src/three-types.ts +++ /dev/null @@ -1,404 +0,0 @@ -import * as THREE from 'three' -import { EventHandlers } from './core/events' -import { AttachType } from './core/renderer' - -export type NonFunctionKeys = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T] -export type Overwrite = Omit> & O - -/** - * If **T** contains a constructor, @see ConstructorParameters must be used, otherwise **T**. - */ -type Args = T extends new (...args: any) => any ? ConstructorParameters : T - -export type Euler = THREE.Euler | Parameters -export type Matrix4 = THREE.Matrix4 | Parameters | Readonly - -/** - * Turn an implementation of THREE.Vector in to the type that an r3f component would accept as a prop. - */ -type VectorLike = - | VectorClass - | Parameters - | Readonly> - | Parameters[0] - -export type Vector2 = VectorLike -export type Vector3 = VectorLike -export type Vector4 = VectorLike -export type Color = ConstructorParameters | THREE.Color | number | string // Parameters will not work here because of multiple function signatures in three.js types -export type ColorArray = typeof THREE.Color | Parameters -export type Layers = THREE.Layers | Parameters[0] -export type Quaternion = THREE.Quaternion | Parameters - -export type AttachCallback = string | ((child: any, parentInstance: any) => void) - -export interface NodeProps { - attach?: AttachType - /** Constructor arguments */ - args?: Args

- children?: React.ReactNode - ref?: React.Ref - key?: React.Key - onUpdate?: (self: T) => void -} - -export type ExtendedColors = { [K in keyof T]: T[K] extends THREE.Color | undefined ? Color : T[K] } -export type Node = ExtendedColors, NodeProps>> - -export type Object3DNode = Overwrite< - Node, - { - position?: Vector3 - up?: Vector3 - scale?: Vector3 - rotation?: Euler - matrix?: Matrix4 - quaternion?: Quaternion - layers?: Layers - dispose?: (() => void) | null - } -> & - EventHandlers - -export type BufferGeometryNode = Node -export type MaterialNode = Node -export type LightNode = Object3DNode - -export type Object3DProps = Object3DNode -// export type AudioProps = Object3DNode -export type AudioListenerProps = Object3DNode -export type PositionalAudioProps = Object3DNode - -export type MeshProps = Object3DNode -export type InstancedMeshProps = Object3DNode -export type SceneProps = Object3DNode -export type SpriteProps = Object3DNode -export type LODProps = Object3DNode -export type SkinnedMeshProps = Object3DNode - -export type SkeletonProps = Object3DNode -export type BoneProps = Object3DNode -export type LineSegmentsProps = Object3DNode -export type LineLoopProps = Object3DNode -// export type LineProps = Object3DNode -export type PointsProps = Object3DNode -export type GroupProps = Object3DNode - -export type CameraProps = Object3DNode -export type PerspectiveCameraProps = Object3DNode -export type OrthographicCameraProps = Object3DNode -export type CubeCameraProps = Object3DNode -export type ArrayCameraProps = Object3DNode - -export type InstancedBufferGeometryProps = BufferGeometryNode< - THREE.InstancedBufferGeometry, - typeof THREE.InstancedBufferGeometry -> -export type BufferGeometryProps = BufferGeometryNode -export type BoxBufferGeometryProps = BufferGeometryNode -export type CircleBufferGeometryProps = BufferGeometryNode< - THREE.CircleBufferGeometry, - typeof THREE.CircleBufferGeometry -> -export type ConeBufferGeometryProps = BufferGeometryNode -export type CylinderBufferGeometryProps = BufferGeometryNode< - THREE.CylinderBufferGeometry, - typeof THREE.CylinderBufferGeometry -> -export type DodecahedronBufferGeometryProps = BufferGeometryNode< - THREE.DodecahedronBufferGeometry, - typeof THREE.DodecahedronBufferGeometry -> -export type ExtrudeBufferGeometryProps = BufferGeometryNode< - THREE.ExtrudeBufferGeometry, - typeof THREE.ExtrudeBufferGeometry -> -export type IcosahedronBufferGeometryProps = BufferGeometryNode< - THREE.IcosahedronBufferGeometry, - typeof THREE.IcosahedronBufferGeometry -> -export type LatheBufferGeometryProps = BufferGeometryNode -export type OctahedronBufferGeometryProps = BufferGeometryNode< - THREE.OctahedronBufferGeometry, - typeof THREE.OctahedronBufferGeometry -> -export type PlaneBufferGeometryProps = BufferGeometryNode -export type PolyhedronBufferGeometryProps = BufferGeometryNode< - THREE.PolyhedronBufferGeometry, - typeof THREE.PolyhedronBufferGeometry -> -export type RingBufferGeometryProps = BufferGeometryNode -export type ShapeBufferGeometryProps = BufferGeometryNode -export type SphereBufferGeometryProps = BufferGeometryNode< - THREE.SphereBufferGeometry, - typeof THREE.SphereBufferGeometry -> -export type TetrahedronBufferGeometryProps = BufferGeometryNode< - THREE.TetrahedronBufferGeometry, - typeof THREE.TetrahedronBufferGeometry -> -export type TorusBufferGeometryProps = BufferGeometryNode -export type TorusKnotBufferGeometryProps = BufferGeometryNode< - THREE.TorusKnotBufferGeometry, - typeof THREE.TorusKnotBufferGeometry -> -export type TubeBufferGeometryProps = BufferGeometryNode -export type WireframeGeometryProps = BufferGeometryNode -export type TetrahedronGeometryProps = BufferGeometryNode -export type OctahedronGeometryProps = BufferGeometryNode -export type IcosahedronGeometryProps = BufferGeometryNode -export type DodecahedronGeometryProps = BufferGeometryNode< - THREE.DodecahedronGeometry, - typeof THREE.DodecahedronGeometry -> -export type PolyhedronGeometryProps = BufferGeometryNode -export type TubeGeometryProps = BufferGeometryNode -export type TorusKnotGeometryProps = BufferGeometryNode -export type TorusGeometryProps = BufferGeometryNode -export type SphereGeometryProps = BufferGeometryNode -export type RingGeometryProps = BufferGeometryNode -export type PlaneGeometryProps = BufferGeometryNode -export type LatheGeometryProps = BufferGeometryNode -export type ShapeGeometryProps = BufferGeometryNode -export type ExtrudeGeometryProps = BufferGeometryNode -export type EdgesGeometryProps = BufferGeometryNode -export type ConeGeometryProps = BufferGeometryNode -export type CylinderGeometryProps = BufferGeometryNode -export type CircleGeometryProps = BufferGeometryNode -export type BoxGeometryProps = BufferGeometryNode -export type CapsuleGeometryProps = BufferGeometryNode - -export type MaterialProps = MaterialNode -export type ShadowMaterialProps = MaterialNode -export type SpriteMaterialProps = MaterialNode -export type RawShaderMaterialProps = MaterialNode -export type ShaderMaterialProps = MaterialNode -export type PointsMaterialProps = MaterialNode -export type MeshPhysicalMaterialProps = MaterialNode -export type MeshStandardMaterialProps = MaterialNode -export type MeshPhongMaterialProps = MaterialNode -export type MeshToonMaterialProps = MaterialNode -export type MeshNormalMaterialProps = MaterialNode -export type MeshLambertMaterialProps = MaterialNode -export type MeshDepthMaterialProps = MaterialNode -export type MeshDistanceMaterialProps = MaterialNode -export type MeshBasicMaterialProps = MaterialNode -export type MeshMatcapMaterialProps = MaterialNode -export type LineDashedMaterialProps = MaterialNode -export type LineBasicMaterialProps = MaterialNode - -export type PrimitiveProps = { object: any } & { [properties: string]: any } - -export type LightProps = LightNode -export type SpotLightShadowProps = Node -export type SpotLightProps = LightNode -export type PointLightProps = LightNode -export type RectAreaLightProps = LightNode -export type HemisphereLightProps = LightNode -export type DirectionalLightShadowProps = Node -export type DirectionalLightProps = LightNode -export type AmbientLightProps = LightNode -export type LightShadowProps = Node -export type AmbientLightProbeProps = LightNode -export type HemisphereLightProbeProps = LightNode -export type LightProbeProps = LightNode - -export type SpotLightHelperProps = Object3DNode -export type SkeletonHelperProps = Object3DNode -export type PointLightHelperProps = Object3DNode -export type HemisphereLightHelperProps = Object3DNode -export type GridHelperProps = Object3DNode -export type PolarGridHelperProps = Object3DNode -export type DirectionalLightHelperProps = Object3DNode< - THREE.DirectionalLightHelper, - typeof THREE.DirectionalLightHelper -> -export type CameraHelperProps = Object3DNode -export type BoxHelperProps = Object3DNode -export type Box3HelperProps = Object3DNode -export type PlaneHelperProps = Object3DNode -export type ArrowHelperProps = Object3DNode -export type AxesHelperProps = Object3DNode - -export type TextureProps = Node -export type VideoTextureProps = Node -export type DataTextureProps = Node -export type DataTexture3DProps = Node -export type CompressedTextureProps = Node -export type CubeTextureProps = Node -export type CanvasTextureProps = Node -export type DepthTextureProps = Node - -export type RaycasterProps = Node -export type Vector2Props = Node -export type Vector3Props = Node -export type Vector4Props = Node -export type EulerProps = Node -export type Matrix3Props = Node -export type Matrix4Props = Node -export type QuaternionProps = Node -export type BufferAttributeProps = Node -export type Float16BufferAttributeProps = Node -export type Float32BufferAttributeProps = Node -export type Float64BufferAttributeProps = Node -export type Int8BufferAttributeProps = Node -export type Int16BufferAttributeProps = Node -export type Int32BufferAttributeProps = Node -export type Uint8BufferAttributeProps = Node -export type Uint16BufferAttributeProps = Node -export type Uint32BufferAttributeProps = Node -export type InstancedBufferAttributeProps = Node -export type ColorProps = Node -export type FogProps = Node -export type FogExp2Props = Node -export type ShapeProps = Node - -export interface ThreeElements { - object3D: Object3DProps - - // `audio` works but conflicts with @types/react. Try using PositionalAudio from @react-three/drei instead - // audio: AudioProps - audioListener: AudioListenerProps - positionalAudio: PositionalAudioProps - - mesh: MeshProps - instancedMesh: InstancedMeshProps - scene: SceneProps - sprite: SpriteProps - lOD: LODProps - skinnedMesh: SkinnedMeshProps - skeleton: SkeletonProps - bone: BoneProps - lineSegments: LineSegmentsProps - lineLoop: LineLoopProps - // see `audio` - // line: LineProps - points: PointsProps - group: GroupProps - - // cameras - camera: CameraProps - perspectiveCamera: PerspectiveCameraProps - orthographicCamera: OrthographicCameraProps - cubeCamera: CubeCameraProps - arrayCamera: ArrayCameraProps - - // geometry - instancedBufferGeometry: InstancedBufferGeometryProps - bufferGeometry: BufferGeometryProps - wireframeGeometry: WireframeGeometryProps - tetrahedronGeometry: TetrahedronGeometryProps - octahedronGeometry: OctahedronGeometryProps - icosahedronGeometry: IcosahedronGeometryProps - dodecahedronGeometry: DodecahedronGeometryProps - polyhedronGeometry: PolyhedronGeometryProps - tubeGeometry: TubeGeometryProps - torusKnotGeometry: TorusKnotGeometryProps - torusGeometry: TorusGeometryProps - sphereGeometry: SphereGeometryProps - ringGeometry: RingGeometryProps - planeGeometry: PlaneGeometryProps - latheGeometry: LatheGeometryProps - shapeGeometry: ShapeGeometryProps - extrudeGeometry: ExtrudeGeometryProps - edgesGeometry: EdgesGeometryProps - coneGeometry: ConeGeometryProps - cylinderGeometry: CylinderGeometryProps - circleGeometry: CircleGeometryProps - boxGeometry: BoxGeometryProps - capsuleGeometry: CapsuleGeometryProps - - // materials - material: MaterialProps - shadowMaterial: ShadowMaterialProps - spriteMaterial: SpriteMaterialProps - rawShaderMaterial: RawShaderMaterialProps - shaderMaterial: ShaderMaterialProps - pointsMaterial: PointsMaterialProps - meshPhysicalMaterial: MeshPhysicalMaterialProps - meshStandardMaterial: MeshStandardMaterialProps - meshPhongMaterial: MeshPhongMaterialProps - meshToonMaterial: MeshToonMaterialProps - meshNormalMaterial: MeshNormalMaterialProps - meshLambertMaterial: MeshLambertMaterialProps - meshDepthMaterial: MeshDepthMaterialProps - meshDistanceMaterial: MeshDistanceMaterialProps - meshBasicMaterial: MeshBasicMaterialProps - meshMatcapMaterial: MeshMatcapMaterialProps - lineDashedMaterial: LineDashedMaterialProps - lineBasicMaterial: LineBasicMaterialProps - - // primitive - primitive: PrimitiveProps - - // lights and other - light: LightProps - spotLightShadow: SpotLightShadowProps - spotLight: SpotLightProps - pointLight: PointLightProps - rectAreaLight: RectAreaLightProps - hemisphereLight: HemisphereLightProps - directionalLightShadow: DirectionalLightShadowProps - directionalLight: DirectionalLightProps - ambientLight: AmbientLightProps - lightShadow: LightShadowProps - ambientLightProbe: AmbientLightProbeProps - hemisphereLightProbe: HemisphereLightProbeProps - lightProbe: LightProbeProps - - // helpers - spotLightHelper: SpotLightHelperProps - skeletonHelper: SkeletonHelperProps - pointLightHelper: PointLightHelperProps - hemisphereLightHelper: HemisphereLightHelperProps - gridHelper: GridHelperProps - polarGridHelper: PolarGridHelperProps - directionalLightHelper: DirectionalLightHelperProps - cameraHelper: CameraHelperProps - boxHelper: BoxHelperProps - box3Helper: Box3HelperProps - planeHelper: PlaneHelperProps - arrowHelper: ArrowHelperProps - axesHelper: AxesHelperProps - - // textures - texture: TextureProps - videoTexture: VideoTextureProps - dataTexture: DataTextureProps - dataTexture3D: DataTexture3DProps - compressedTexture: CompressedTextureProps - cubeTexture: CubeTextureProps - canvasTexture: CanvasTextureProps - depthTexture: DepthTextureProps - - // misc - raycaster: RaycasterProps - vector2: Vector2Props - vector3: Vector3Props - vector4: Vector4Props - euler: EulerProps - matrix3: Matrix3Props - matrix4: Matrix4Props - quaternion: QuaternionProps - bufferAttribute: BufferAttributeProps - float16BufferAttribute: Float16BufferAttributeProps - float32BufferAttribute: Float32BufferAttributeProps - float64BufferAttribute: Float64BufferAttributeProps - int8BufferAttribute: Int8BufferAttributeProps - int16BufferAttribute: Int16BufferAttributeProps - int32BufferAttribute: Int32BufferAttributeProps - uint8BufferAttribute: Uint8BufferAttributeProps - uint16BufferAttribute: Uint16BufferAttributeProps - uint32BufferAttribute: Uint32BufferAttributeProps - instancedBufferAttribute: InstancedBufferAttributeProps - color: ColorProps - fog: FogProps - fogExp2: FogExp2Props - shape: ShapeProps -} - -declare global { - namespace JSX { - interface IntrinsicElements extends ThreeElements {} - } -} diff --git a/packages/fiber/src/web/Canvas.tsx b/packages/fiber/src/web/Canvas.tsx index 244887b803..33713cc676 100644 --- a/packages/fiber/src/web/Canvas.tsx +++ b/packages/fiber/src/web/Canvas.tsx @@ -58,7 +58,7 @@ export const Canvas = /*#__PURE__*/ React.forwardRef extend(THREE), []) + React.useMemo(() => extend(THREE as any), []) const [containerRef, containerRect] = useMeasure({ scroll: true, debounce: { scroll: 50, resize: 0 }, ...resize }) const canvasRef = React.useRef(null!) diff --git a/packages/fiber/tests/core/renderer.test.tsx b/packages/fiber/tests/core/renderer.test.tsx index aaf0ab0ead..71596d0b91 100644 --- a/packages/fiber/tests/core/renderer.test.tsx +++ b/packages/fiber/tests/core/renderer.test.tsx @@ -3,19 +3,10 @@ import * as THREE from 'three' import { createCanvas } from '@react-three/test-renderer/src/createTestCanvas' import { createWebGLContext } from '@react-three/test-renderer/src/createWebGLContext' -import { - ReconcilerRoot, - createRoot, - act, - useFrame, - extend, - ReactThreeFiber, - useThree, - createPortal, -} from '../../src/index' +import { ReconcilerRoot, createRoot, act, useFrame, extend, useThree, createPortal } from '../../src/index' import { UseBoundStore } from 'zustand' import { privateKeys, RootState } from '../../src/core/store' -import { Instance } from '../../src/core/renderer' +import { Instance, Node } from '../../src/core/types' import { suspend } from 'suspend-react' type ComponentMesh = THREE.Mesh @@ -53,9 +44,9 @@ extend({ HasObject3dMember, HasObject3dMethods }) declare module '@react-three/fiber' { interface ThreeElements { - hasObject3dMember: ReactThreeFiber.Node - hasObject3dMethods: ReactThreeFiber.Node - myColor: ReactThreeFiber.Node + hasObject3dMember: Node + hasObject3dMethods: Node + myColor: Node } } diff --git a/packages/fiber/tests/setupTests.ts b/packages/fiber/tests/setupTests.ts index 48dd0d8686..c79798959e 100644 --- a/packages/fiber/tests/setupTests.ts +++ b/packages/fiber/tests/setupTests.ts @@ -20,4 +20,4 @@ function getContext(contextId: string): RenderingContext | null { HTMLCanvasElement.prototype.getContext = getContext // Extend catalogue for render API in tests -extend(THREE) +extend(THREE as any) diff --git a/packages/test-renderer/src/index.tsx b/packages/test-renderer/src/index.tsx index ba04dfe235..86b0c2d720 100644 --- a/packages/test-renderer/src/index.tsx +++ b/packages/test-renderer/src/index.tsx @@ -16,7 +16,7 @@ import type { CreateOptions, Renderer, Act } from './types/public' import { wrapFiber } from './createTestInstance' // Extend catalogue for render API in tests. -extend(THREE) +extend(THREE as any) const act = _act as unknown as Act From 16092cf8737a919aa3a6cd1828f28161b7c8d636 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 28 Aug 2022 14:57:49 -0500 Subject: [PATCH 07/58] fix: don't overwrite dispose --- packages/fiber/src/core/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index b0a72fcbd5..c46032f9cf 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -221,6 +221,8 @@ const RESERVED_PROPS = [ 'dispose', 'attach', // 'object', -- internal to primitives + // Behavior flags + 'dispose', ] // This function prepares a set of changes to be applied to the instance From 98d53e2fe8d0062d6c6274f031037bf6fbc7a6ae Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Mon, 29 Aug 2022 02:07:29 -0500 Subject: [PATCH 08/58] fix: don't leak react internals into instance props --- packages/fiber/src/core/renderer.ts | 23 +- packages/fiber/src/core/utils.ts | 33 +- .../__snapshots__/RTTR.core.test.tsx.snap | 331 +----------------- 3 files changed, 44 insertions(+), 343 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index 253a93aa83..ecbeb8512e 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -33,7 +33,7 @@ const extend = (objects: Partial): void => void Object.assign(catalog function createInstance( type: string, - { args = [], object, ...props }: InstanceProps, + props: HostConfig['props'], root: UseBoundStore, ): HostConfig['instance'] { const name = `${type[0].toUpperCase()}${type.slice(1)}` @@ -44,12 +44,13 @@ function createInstance( `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!`) + if (type === 'primitive' && !props.object) throw new Error(`R3F: Primitives without 'object' are invalid!`) // 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!') + if (props.args !== undefined && !Array.isArray(props.args)) throw new Error('R3F: The args prop must be an array!') - const instance = prepare(object ?? new target(...args), root, type, { ...props, args }) + const object = props.object ?? new target(...(props.args ?? [])) + const instance = prepare(object, root, type, props) // Auto-attach geometries and materials if (instance.props.attach === undefined) { @@ -167,9 +168,6 @@ function switchInstance( props: HostConfig['props'], fiber: Reconciler.Fiber, ) { - const parent = oldInstance.parent - if (!parent) return - // Create a new instance const newInstance = createInstance(type, props, oldInstance.root) @@ -177,11 +175,14 @@ function switchInstance( for (const child of oldInstance.children) { appendChild(newInstance, child) } + oldInstance.children = [] // Link up new instance - appendChild(parent, newInstance) - removeChild(parent, oldInstance, false) - oldInstance.children = [] + const parent = oldInstance.parent + if (parent) { + appendChild(parent, newInstance) + removeChild(parent, oldInstance, false) + } // Re-bind event handlers if (newInstance.props.raycast !== null && newInstance.object instanceof THREE.Object3D && newInstance.eventCount) { @@ -272,7 +273,7 @@ const reconciler = Reconciler< if (reconstruct) return switchInstance(instance, type, newProps, fiber) // Otherwise just overwrite props - Object.assign(instance.props, newProps) + Object.assign(instance.props, changedProps) applyProps(instance.object, changedProps) }, // https://github.com/facebook/react/issues/20271 diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index c46032f9cf..29bc4dceb3 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -1,5 +1,6 @@ import * as THREE from 'three' import * as React from 'react' +import type { Fiber } from 'react-reconciler' import type { UseBoundStore } from 'zustand' import type { EventHandlers } from './events' import type { Dpr, RootState, Size } from './store' @@ -134,27 +135,42 @@ export function dispose void; type?: string; [key } } +const REACT_INTERNAL_PROPS = ['children', 'key', 'ref'] + +// Gets only instance props from reconciler fibers +function getInstanceProps(queue: Fiber['pendingProps']): InstanceProps { + const props: Record = {} + + for (const key in queue) { + if (!REACT_INTERNAL_PROPS.includes(key)) props[key] = queue[key] + } + + return props as InstanceProps +} + // Each object in the scene carries a small LocalState descriptor export function prepare( - object: T, + target: T, root: UseBoundStore, type: string, - { args = [], ...props }: InstanceProps, + props: InstanceProps, ) { + const object = target as unknown as Instance['object'] + // Create instance descriptor - let instance: Instance | undefined = (object as unknown as Instance['object']).__r3f + let instance = object.__r3f if (!instance) { instance = { root, type, parent: null, children: [], - props: { ...props, args }, - object: object as unknown as Instance['object'], + props: getInstanceProps(props), + object, eventCount: 0, handlers: {}, } - ;(object as unknown as Instance['object']).__r3f = instance + object.__r3f = instance } return instance @@ -212,10 +228,7 @@ export function detach(parent: Instance, child: Instance) { const DEFAULT = '__default' const RESERVED_PROPS = [ - // React internal props - 'children', - 'key', - 'ref', + ...REACT_INTERNAL_PROPS, // Instance props 'args', 'dispose', 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 aab79e87fb..6d7b8f81dc 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 @@ -55,7 +55,6 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [], "array": Float32Array [ -1, -1, @@ -84,106 +83,24 @@ Array [ }, ], "props": Object { - "args": Array [], "attach": "geometry", - "children": , }, "type": "bufferGeometry", }, Object { "children": Array [], "props": Object { - "args": Array [], "attach": "material", "color": "hotpink", }, "type": "meshBasicMaterial", }, ], - "props": Object { - "args": Array [], - "children": Array [ - - - , - , - ], - }, + "props": Object {}, "type": "mesh", }, ], "props": Object { - "args": Array [], - "children": Array [ - , - , - , - ], "position": Array [ 1, 2, @@ -213,26 +130,12 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [], "attach": "material", }, "type": "meshBasicMaterial", }, ], - "props": Object { - "args": Array [], - "children": Array [ - , - , - ], - }, + "props": Object {}, "type": "mesh", }, ] @@ -256,26 +159,12 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [], "attach": "material", }, "type": "meshStandardMaterial", }, ], - "props": Object { - "args": Array [], - "children": Array [ - , - , - ], - }, + "props": Object {}, "type": "mesh", }, ] @@ -298,19 +187,7 @@ Array [ "type": "color", }, ], - "props": Object { - "args": Array [], - "children": , - }, + "props": Object {}, "type": "group", }, Object { @@ -328,19 +205,7 @@ Array [ "type": "color", }, ], - "props": Object { - "args": Array [], - "children": , - }, + "props": Object {}, "type": "group", }, Object { @@ -358,19 +223,7 @@ Array [ "type": "color", }, ], - "props": Object { - "args": Array [], - "children": , - }, + "props": Object {}, "type": "group", }, ] @@ -380,9 +233,7 @@ exports[`ReactThreeTestRenderer Core toTree() handles nested Fragments 1`] = ` Array [ Object { "children": Array [], - "props": Object { - "args": Array [], - }, + "props": Object {}, "type": "group", }, ] @@ -408,25 +259,12 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [], "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], - "children": Array [ - , - , - ], "position-z": 12, }, "type": "mesh", @@ -447,25 +285,12 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [], "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], - "children": Array [ - , - , - ], "position-y": 12, }, "type": "mesh", @@ -486,74 +311,18 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [], "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], - "children": Array [ - , - , - ], "position-x": 12, }, "type": "mesh", }, ], - "props": Object { - "args": Array [], - "children": Array [ - - - - , - - - - , - - - - , - ], - }, + "props": Object {}, "type": "group", }, ] @@ -579,25 +348,12 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [], "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], - "children": Array [ - , - , - ], "rotation-x": 1, }, "type": "mesh", @@ -618,25 +374,12 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [], "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], - "children": Array [ - , - , - ], "position-y": 12, }, "type": "mesh", @@ -646,7 +389,6 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [], "attach": "material", }, "type": "meshBasicMaterial", @@ -664,67 +406,12 @@ Array [ }, ], "props": Object { - "args": Array [], - "children": Array [ - , - , - ], "position-x": 12, }, "type": "mesh", }, ], - "props": Object { - "args": Array [], - "children": Array [ - - - - , - - - - , - - - - , - ], - }, + "props": Object {}, "type": "group", }, ] From 1a31024df6f5dcbcaf708cd7b9b8b72dd5ffdb07 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Mon, 29 Aug 2022 02:09:44 -0500 Subject: [PATCH 09/58] fix: also exclude primitive props --- packages/fiber/src/core/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 29bc4dceb3..a3948a8f02 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -233,7 +233,7 @@ const RESERVED_PROPS = [ 'args', 'dispose', 'attach', - // 'object', -- internal to primitives + 'object', // Behavior flags 'dispose', ] From 5799b6bbf2a7c6430c9c7cdf7c9c61b929aa9013 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Mon, 29 Aug 2022 02:32:50 -0500 Subject: [PATCH 10/58] chore: cleanup event types --- packages/fiber/src/core/events.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/fiber/src/core/events.ts b/packages/fiber/src/core/events.ts index da719fadac..fe12acd00e 100644 --- a/packages/fiber/src/core/events.ts +++ b/packages/fiber/src/core/events.ts @@ -173,7 +173,8 @@ export function createEvents(store: UseBoundStore) { function filterPointerEvents(objects: THREE.Object3D[]) { return objects.filter((obj) => ['Move', 'Over', 'Enter', 'Out', 'Leave'].some( - (name) => ((obj as any).__r3f as Instance | undefined)?.handlers[('onPointer' + name) as keyof EventHandlers], + (name) => + (obj as Instance['object']).__r3f?.handlers[('onPointer' + name) as keyof EventHandlers], ), ) } @@ -239,7 +240,7 @@ export function createEvents(store: UseBoundStore) { let eventObject: THREE.Object3D | null = hit.object // Bubble event up while (eventObject) { - if (((eventObject as any).__r3f as Instance | undefined)?.eventCount) + if ((eventObject as Instance['object']).__r3f?.eventCount) intersections.push({ ...hit, eventObject }) eventObject = eventObject.parent } @@ -370,7 +371,7 @@ export function createEvents(store: UseBoundStore) { ) ) { const eventObject = hoveredObj.eventObject - const instance = (eventObject as any).__r3f as Instance | undefined + const instance = (eventObject as Instance['object']).__r3f internal.hovered.delete(makeId(hoveredObj)) if (instance?.eventCount) { const handlers = instance.handlers @@ -435,7 +436,7 @@ export function createEvents(store: UseBoundStore) { handleIntersects(hits, event, delta, (data: ThreeEvent) => { const eventObject = data.eventObject - const instance = (eventObject as any).__r3f as Instance | undefined + const instance = (eventObject as Instance['object']).__r3f // Check presence of handlers if (!instance?.eventCount) return @@ -490,7 +491,7 @@ export function createEvents(store: UseBoundStore) { function pointerMissed(event: MouseEvent, objects: THREE.Object3D[]) { objects.forEach((object: THREE.Object3D) => - ((object as any).__r3f as Instance | undefined)?.handlers.onPointerMissed?.(event), + (object as Instance['object']).__r3f?.handlers.onPointerMissed?.(event), ) } From 53c24d1f5e87f17f8cf861f71890d8adf0531d30 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Mon, 29 Aug 2022 16:45:06 -0500 Subject: [PATCH 11/58] fix: add back three-types, export ThreeElements as interface --- example/src/demos/Lines.tsx | 2 +- example/src/demos/Pointcloud.tsx | 2 +- example/typings/global.d.ts | 6 +-- packages/fiber/src/core/events.ts | 2 +- packages/fiber/src/core/index.tsx | 18 ++++---- packages/fiber/src/core/renderer.ts | 39 ++++++++++++++-- packages/fiber/src/core/utils.ts | 25 +++++----- packages/fiber/src/index.tsx | 23 +++++----- packages/fiber/src/native.tsx | 23 +++++----- .../src/{core/types.ts => three-types.ts} | 46 ++++--------------- packages/fiber/tests/core/renderer.test.tsx | 19 ++++++-- 11 files changed, 110 insertions(+), 95 deletions(-) rename packages/fiber/src/{core/types.ts => three-types.ts} (53%) diff --git a/example/src/demos/Lines.tsx b/example/src/demos/Lines.tsx index 98c8e07582..600d88f7d0 100644 --- a/example/src/demos/Lines.tsx +++ b/example/src/demos/Lines.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect, useState, useCallback, useContext, useMemo } from 'react' -import { extend, Canvas, useThree } from '@react-three/fiber' +import { extend, Canvas, useThree, ReactThreeFiber } from '@react-three/fiber' import { OrbitControls } from 'three-stdlib' extend({ OrbitControls }) diff --git a/example/src/demos/Pointcloud.tsx b/example/src/demos/Pointcloud.tsx index 57870cf450..ef8927cb6a 100644 --- a/example/src/demos/Pointcloud.tsx +++ b/example/src/demos/Pointcloud.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect, useState, useCallback, useContext, useMemo } from 'react' -import { extend, Canvas, useThree } from '@react-three/fiber' +import { extend, Canvas, useThree, ReactThreeFiber } from '@react-three/fiber' import * as THREE from 'three' import { OrbitControls } from 'three-stdlib' extend({ OrbitControls }) diff --git a/example/typings/global.d.ts b/example/typings/global.d.ts index 5aac79358c..aa5f92d155 100644 --- a/example/typings/global.d.ts +++ b/example/typings/global.d.ts @@ -1,10 +1,10 @@ -import { Node } from '@react-three/fiber' +import { ReactThreeFiber } from '@react-three/fiber' import { OrbitControls } from 'three-stdlib' import { DotMaterial } from '../src/demos/Pointcloud' declare module '@react-three/fiber' { interface ThreeElements { - orbitControls: Node - dotMaterial: Node + orbitControls: ReactThreeFiber.Node + dotMaterial: ReactThreeFiber.Node } } diff --git a/packages/fiber/src/core/events.ts b/packages/fiber/src/core/events.ts index fe12acd00e..540b4b2dd0 100644 --- a/packages/fiber/src/core/events.ts +++ b/packages/fiber/src/core/events.ts @@ -2,7 +2,7 @@ import * as THREE from 'three' import { ContinuousEventPriority, DiscreteEventPriority, DefaultEventPriority } from 'react-reconciler/constants' import { getRootState } from './utils' import type { UseBoundStore } from 'zustand' -import type { Instance } from './types' +import type { Instance } from './renderer' import type { RootState } from './store' export interface Intersection extends THREE.Intersection { diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index 6d62c54a41..d4fd35633a 100644 --- a/packages/fiber/src/core/index.tsx +++ b/packages/fiber/src/core/index.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import { ConcurrentRoot } from 'react-reconciler/constants' import create, { StoreApi, UseBoundStore } from 'zustand' +import * as ReactThreeFiber from '../three-types' import { Renderer, createStore, @@ -35,7 +36,6 @@ import { import { useStore } from './hooks' import { Stage, Lifecycle, Stages } from './stages' import { OffscreenCanvas } from 'three' -import { InstanceProps } from './types' const roots = new Map() const { invalidate, advance } = createLoop(roots) @@ -87,9 +87,11 @@ export type RenderProps = { /** A `THREE.Camera` instance or props that go into the default camera */ camera?: ( | Camera - | InstanceProps - | InstanceProps - | InstanceProps + | Partial< + ReactThreeFiber.Node & + ReactThreeFiber.Node & + ReactThreeFiber.Node + > ) & { /** Flags the camera as manual, putting projection into your own hands */ manual?: boolean @@ -240,9 +242,9 @@ function createRoot(canvas: TCanvas): ReconcilerRoot(canvas: TCanvas): ReconcilerRoot(canvas: TCanvas): ReconcilerRoot } +export type AttachFnType = (parent: any, self: O) => () => void +export type AttachType = string | AttachFnType + +export type ConstructorRepresentation = new (...args: any[]) => any + +export interface Catalogue { + [name: string]: ConstructorRepresentation +} + +export type Args = T extends ConstructorRepresentation ? ConstructorParameters : any[] + +export type InstanceProps = { + args?: Args + object?: T + visible?: boolean + dispose?: null + attach?: AttachType +} + +export interface Instance { + root: UseBoundStore + type: string + parent: Instance | null + children: Instance[] + props: InstanceProps + object: O & { __r3f?: Instance } + eventCount: number + handlers: Partial + attach?: AttachType + previousAttach?: any +} + interface HostConfig { type: string props: InstanceProps @@ -185,7 +216,7 @@ function switchInstance( } // Re-bind event handlers - if (newInstance.props.raycast !== null && newInstance.object instanceof THREE.Object3D && newInstance.eventCount) { + if (newInstance.object.raycast !== null && newInstance.object instanceof THREE.Object3D && newInstance.eventCount) { const rootState = newInstance.root.getState() rootState.internal.interaction.push(newInstance.object) } @@ -280,7 +311,7 @@ const reconciler = Reconciler< // This will make sure events are only added once to the central container finalizeInitialChildren: (instance) => instance.eventCount > 0, commitMount(instance) { - if (instance.props.raycast !== null && instance.object instanceof THREE.Object3D && instance.eventCount) { + if (instance.object.raycast !== null && instance.object instanceof THREE.Object3D && instance.eventCount) { const rootState = instance.root.getState() rootState.internal.interaction.push(instance.object) } diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index a3948a8f02..8d7167902a 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -4,7 +4,7 @@ import type { Fiber } from 'react-reconciler' import type { UseBoundStore } from 'zustand' import type { EventHandlers } from './events' import type { Dpr, RootState, Size } from './store' -import type { Instance, InstanceProps } from './types' +import type { Instance, InstanceProps } from './renderer' export type Camera = THREE.OrthographicCamera | THREE.PerspectiveCamera export const isOrthographicCamera = (def: Camera): def is THREE.OrthographicCamera => @@ -149,12 +149,7 @@ function getInstanceProps(queue: Fiber['pendingProps']): InstanceProps< } // Each object in the scene carries a small LocalState descriptor -export function prepare( - target: T, - root: UseBoundStore, - type: string, - props: InstanceProps, -) { +export function prepare(target: T, root: UseBoundStore, type: string, props: InstanceProps) { const object = target as unknown as Instance['object'] // Create instance descriptor @@ -240,17 +235,17 @@ const RESERVED_PROPS = [ // This function prepares a set of changes to be applied to the instance export function diffProps(newProps: InstanceProps, oldProps: InstanceProps, remove = false): InstanceProps { - const changedProps: InstanceProps = {} + const changedProps: Record = {} // 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 + if (is.equ((newProps as Record)[key], (oldProps as Record)[key])) continue // Props changed, add them - changedProps[key] = newProps[key] + changedProps[key] = (newProps as Record)[key] } // Catch removed props, prepend them so they can be reset or removed @@ -261,17 +256,17 @@ export function diffProps(newProps: InstanceProps, oldProps: InstanceProps, remo } } - return changedProps + return changedProps as InstanceProps } // This function applies a set of changes to the instance -export function applyProps(object: Instance['object'], props: InstanceProps) { +export function applyProps(object: Instance['object'], props: InstanceProps) { const instance = object.__r3f const rootState = instance?.root.getState() const prevHandlers = instance?.eventCount for (const prop in props) { - let value = props[prop] + let value = (props as Record)[prop] // Don't mutate reserved keys if (RESERVED_PROPS.includes(prop)) continue @@ -355,7 +350,9 @@ export function applyProps(object: Instance['object'], props: InstanceProps) { const index = rootState.internal.interaction.indexOf(instance.object) if (index > -1) rootState.internal.interaction.splice(index, 1) // Add the instance to the interaction manager only when it has handlers - if (instance.eventCount && instance.props.raycast !== null) rootState.internal.interaction.push(instance.object) + if (instance.eventCount && instance.object.raycast !== null && instance.object instanceof THREE.Object3D) { + rootState.internal.interaction.push(instance.object) + } } if (instance) invalidateInstance(instance) diff --git a/packages/fiber/src/index.tsx b/packages/fiber/src/index.tsx index f245bb1d09..9f68608ebe 100644 --- a/packages/fiber/src/index.tsx +++ b/packages/fiber/src/index.tsx @@ -1,14 +1,15 @@ -import { Node, ThreeElements } from './core/types' - -declare global { - namespace JSX { - interface IntrinsicElements extends ThreeElements { - primitive: Omit, 'args'> - } - } -} - -export type { AttachFnType, AttachType, Node, ThreeElements, Catalogue, Instance, InstanceProps } from './core/types' +export * from './three-types' +import * as ReactThreeFiber from './three-types' +export { ReactThreeFiber } +export type { + AttachFnType, + AttachType, + ConstructorRepresentation, + Catalogue, + Args, + InstanceProps, + Instance, +} from './core/renderer' export type { Intersection, Subscription, diff --git a/packages/fiber/src/native.tsx b/packages/fiber/src/native.tsx index 346193b7c3..62d225bd29 100644 --- a/packages/fiber/src/native.tsx +++ b/packages/fiber/src/native.tsx @@ -1,14 +1,15 @@ -import { Node, ThreeElements } from './core/types' - -declare global { - namespace JSX { - interface IntrinsicElements extends ThreeElements { - primitive: Omit, 'args'> - } - } -} - -export type { AttachFnType, AttachType, Node, ThreeElements, Catalogue, Instance, InstanceProps } from './core/types' +export * from './three-types' +import * as ReactThreeFiber from './three-types' +export { ReactThreeFiber } +export type { + AttachFnType, + AttachType, + ConstructorRepresentation, + Catalogue, + Args, + InstanceProps, + Instance, +} from './core/renderer' export type { Intersection, Subscription, diff --git a/packages/fiber/src/core/types.ts b/packages/fiber/src/three-types.ts similarity index 53% rename from packages/fiber/src/core/types.ts rename to packages/fiber/src/three-types.ts index 8333395c55..0ea358744b 100644 --- a/packages/fiber/src/core/types.ts +++ b/packages/fiber/src/three-types.ts @@ -1,17 +1,11 @@ import type * as THREE from 'three' -import type { UseBoundStore } from 'zustand' -import type { EventHandlers } from './events' -import type { RootState } from './store' - -export type AttachFnType = (parent: any, self: O) => () => void -export type AttachType = string | AttachFnType +import type { EventHandlers } from './core/events' +import type { InstanceProps, ConstructorRepresentation } from './core/renderer' type Mutable = { [K in keyof T]: T[K] | Readonly } type NonFunctionKeys = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T] type WithoutFunctions = Pick> type Overwrite = Omit> & O -type ConstructorRepresentation = new (...args: any[]) => any -type Args = T extends ConstructorRepresentation ? ConstructorParameters : any[] interface MathRepresentation { set(...args: any[]): any @@ -36,52 +30,32 @@ interface RaycastableRepresentation { } type EventProps = T extends RaycastableRepresentation ? EventHandlers : {} -export type InstanceProps = { - args?: Args - object?: T - visible?: boolean - dispose?: null - attach?: AttachType -} & Partial & EventProps

> - -export interface Instance { - root: UseBoundStore - type: string - parent: Instance | null - children: Instance[] - props: InstanceProps - object: O & { __r3f?: Instance } - eventCount: number - handlers: Partial - attach?: AttachType - previousAttach?: any -} - interface ReactProps { children?: React.ReactNode ref?: React.Ref key?: React.Key } +type NodeProps = InstanceProps & + Partial & MathProps

& EventProps

> + export type Node = Mutable< - Overwrite>, InstanceProps & ReactProps> + Overwrite>, Omit, 'object'>> > type ThreeExports = typeof THREE -export type ThreeElements = { +type ThreeElementsImpl = { [K in keyof ThreeExports as Uncapitalize]: ThreeExports[K] extends ConstructorRepresentation - ? Omit, 'object'> + ? Node : never } -export interface Catalogue { - [name: string]: ConstructorRepresentation -} +export interface ThreeElements extends ThreeElementsImpl {} declare global { namespace JSX { interface IntrinsicElements extends ThreeElements { - primitive: Omit, 'args'> + primitive: Omit, 'args'> } } } diff --git a/packages/fiber/tests/core/renderer.test.tsx b/packages/fiber/tests/core/renderer.test.tsx index 71596d0b91..6f5a6147d5 100644 --- a/packages/fiber/tests/core/renderer.test.tsx +++ b/packages/fiber/tests/core/renderer.test.tsx @@ -3,10 +3,19 @@ import * as THREE from 'three' import { createCanvas } from '@react-three/test-renderer/src/createTestCanvas' import { createWebGLContext } from '@react-three/test-renderer/src/createWebGLContext' -import { ReconcilerRoot, createRoot, act, useFrame, extend, useThree, createPortal } from '../../src/index' +import { + ReconcilerRoot, + createRoot, + act, + useFrame, + extend, + ReactThreeFiber, + useThree, + createPortal, +} from '../../src/index' import { UseBoundStore } from 'zustand' import { privateKeys, RootState } from '../../src/core/store' -import { Instance, Node } from '../../src/core/types' +import { Instance } from '../../src/core/renderer' import { suspend } from 'suspend-react' type ComponentMesh = THREE.Mesh @@ -44,9 +53,9 @@ extend({ HasObject3dMember, HasObject3dMethods }) declare module '@react-three/fiber' { interface ThreeElements { - hasObject3dMember: Node - hasObject3dMethods: Node - myColor: Node + hasObject3dMember: ReactThreeFiber.Node + hasObject3dMethods: ReactThreeFiber.Node + myColor: ReactThreeFiber.Node } } From ab97027c84b84436440ccaa480dbf9ea4743d6f4 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Mon, 29 Aug 2022 17:30:40 -0500 Subject: [PATCH 12/58] chore(types): cleanup --- packages/fiber/src/core/utils.ts | 40 ++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 8d7167902a..a4bfcbf9e9 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -24,7 +24,7 @@ export const useIsomorphicLayoutEffect = ? React.useLayoutEffect : React.useEffect -export function useMutableCallback(fn: T) { +export function useMutableCallback(fn: T): React.MutableRefObject { const ref = React.useRef(fn) useIsomorphicLayoutEffect(() => void (ref.current = fn), [fn]) return ref @@ -62,15 +62,15 @@ export type ObjectMap = { materials: { [name: string]: THREE.Material } } -export function calculateDpr(dpr: Dpr) { +export function calculateDpr(dpr: Dpr): number { return Array.isArray(dpr) ? Math.min(Math.max(dpr[0], window.devicePixelRatio), dpr[1]) : dpr } /** * Returns instance root state */ -export const getRootState = (obj: THREE.Object3D): RootState | undefined => - ((obj as any).__r3f as Instance)?.root.getState() +export const getRootState = (obj: T): RootState | undefined => + (obj as Instance['object']).__r3f?.root.getState() export type EquConfig = { /** Compare arrays by reference equality a === b (default), or by shallow equality */ @@ -115,7 +115,7 @@ export const is = { } // Collects nodes and materials from a THREE.Object3D -export function buildGraph(object: THREE.Object3D) { +export function buildGraph(object: THREE.Object3D): ObjectMap { const data: ObjectMap = { nodes: {}, materials: {} } if (object) { object.traverse((obj: any) => { @@ -126,11 +126,16 @@ export function buildGraph(object: THREE.Object3D) { return data } +interface Disposable { + type?: string + dispose?: () => void +} + // Disposes an object and all its properties -export function dispose void; type?: string; [key: string]: any }>(obj: TObj) { - if (obj.dispose && obj.type !== 'Scene') obj.dispose() +export function dispose(obj: T): void { + if (obj.type !== 'Scene') obj.dispose?.() for (const p in obj) { - ;(p as any).dispose?.() + ;(p as Disposable).dispose?.() delete obj[p] } } @@ -149,7 +154,12 @@ function getInstanceProps(queue: Fiber['pendingProps']): InstanceProps< } // Each object in the scene carries a small LocalState descriptor -export function prepare(target: T, root: UseBoundStore, type: string, props: InstanceProps) { +export function prepare( + target: T, + root: UseBoundStore, + type: string, + props: InstanceProps, +): Instance { const object = target as unknown as Instance['object'] // Create instance descriptor @@ -171,7 +181,7 @@ export function prepare(target: T, root: UseBoundStore, type return instance } -function resolve(root: any, key: string) { +function resolve(root: any, key: string): { root: any; key: string; target: any } { let target = root[key] if (!key.includes('-')) return { root, key, target } @@ -189,7 +199,7 @@ function resolve(root: any, key: string) { // Checks if a dash-cased string ends with an integer const INDEX_REGEX = /-\d+$/ -export function attach(parent: Instance, child: Instance) { +export function attach(parent: Instance, child: Instance): void { if (is.str(child.props.attach)) { // If attaching into an array (foo-0), create one if (INDEX_REGEX.test(child.props.attach)) { @@ -206,7 +216,7 @@ export function attach(parent: Instance, child: Instance) { } } -export function detach(parent: Instance, child: Instance) { +export function detach(parent: Instance, child: Instance): void { if (is.str(child.props.attach)) { const { root, key } = resolve(parent.object, child.props.attach) const previous = child.previousAttach @@ -260,7 +270,7 @@ export function diffProps(newProps: InstanceProps, oldProps: InstanceProps, remo } // This function applies a set of changes to the instance -export function applyProps(object: Instance['object'], props: InstanceProps) { +export function applyProps(object: Instance['object'], props: InstanceProps): Instance['object'] { const instance = object.__r3f const rootState = instance?.root.getState() const prevHandlers = instance?.eventCount @@ -360,12 +370,12 @@ export function applyProps(object: Instance['object'], props: Instan return object } -export function invalidateInstance(instance: Instance) { +export function invalidateInstance(instance: Instance): void { const state = instance.root?.getState?.() if (state && state.internal.frames === 0) state.invalidate() } -export function updateCamera(camera: Camera & { manual?: boolean }, size: Size) { +export function updateCamera(camera: Camera & { manual?: boolean }, size: Size): void { // https://github.com/pmndrs/react-three-fiber/issues/92 // Do not mess with the camera if it belongs to the user if (!camera.manual) { From 3ef4354f0d1202c5fb697b5f1e054239298e9271 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Mon, 29 Aug 2022 17:31:15 -0500 Subject: [PATCH 13/58] fix: check obj type in dispose, reconstruct with args length --- packages/fiber/src/core/index.tsx | 2 +- packages/fiber/src/core/renderer.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index d4fd35633a..4e36ee6caf 100644 --- a/packages/fiber/src/core/index.tsx +++ b/packages/fiber/src/core/index.tsx @@ -422,7 +422,7 @@ function unmountComponentAtNode(canvas: TElement, call state.gl?.renderLists?.dispose?.() state.gl?.forceContextLoss?.() if (state.gl?.xr) state.xr.disconnect() - dispose(state) + dispose(state.scene) roots.delete(canvas) if (callback) callback(canvas) } catch (e) { diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index 1e00449e03..8df8aebd9a 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -177,8 +177,8 @@ function removeChild( if (!isPrimitive && recursive) removeRecursive(child.children, child, shouldDispose) // Dispose object whenever the reconciler feels like it - if (child.type !== 'scene' && shouldDispose) { - const dispose = (child.object as unknown as any).dispose + if (child.object.type !== 'Scene' && shouldDispose) { + const dispose = child.object.dispose if (typeof dispose === 'function') { scheduleCallback(idlePriority, () => { try { @@ -287,7 +287,8 @@ const reconciler = Reconciler< // Reconstruct primitives if object prop changes if (instance.type === 'primitive' && oldProps.object !== newProps.object) return [true] - // Reconstruct elements if args change + // Reconstruct instance if args change + if (newProps.args?.length !== oldProps.args?.length) return [true] if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) return [true] // Create a diff-set, flag if there are any changes From a8e922545e1524f152021f629ba98c795430ab96 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Mon, 29 Aug 2022 17:51:57 -0500 Subject: [PATCH 14/58] fix(types): move primitive to ThreeElements --- packages/fiber/src/three-types.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/fiber/src/three-types.ts b/packages/fiber/src/three-types.ts index 0ea358744b..f9bbfce5ad 100644 --- a/packages/fiber/src/three-types.ts +++ b/packages/fiber/src/three-types.ts @@ -50,12 +50,12 @@ type ThreeElementsImpl = { : never } -export interface ThreeElements extends ThreeElementsImpl {} +export interface ThreeElements extends ThreeElementsImpl { + primitive: Omit, 'args'> +} declare global { namespace JSX { - interface IntrinsicElements extends ThreeElements { - primitive: Omit, 'args'> - } + interface IntrinsicElements extends ThreeElements {} } } From 8ffad25f5c662d601d7d4f080a6f54bb5b01a56d Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Tue, 30 Aug 2022 02:57:15 -0500 Subject: [PATCH 15/58] chore(types): cleanup helper signatures, require object in primitives --- packages/fiber/src/three-types.ts | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/fiber/src/three-types.ts b/packages/fiber/src/three-types.ts index f9bbfce5ad..35b5b4fdbc 100644 --- a/packages/fiber/src/three-types.ts +++ b/packages/fiber/src/three-types.ts @@ -2,10 +2,10 @@ import type * as THREE from 'three' import type { EventHandlers } from './core/events' import type { InstanceProps, ConstructorRepresentation } from './core/renderer' -type Mutable = { [K in keyof T]: T[K] | Readonly } -type NonFunctionKeys = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T] -type WithoutFunctions = Pick> -type Overwrite = Omit> & O +type Mutable

= { [K in keyof P]: P[K] | Readonly } +type NonFunctionKeys

= { [K in keyof P]: P[K] extends Function ? never : K }[keyof P] +type WithoutFunctions

= Pick> +type Overwrite = Omit> & O interface MathRepresentation { set(...args: any[]): any @@ -13,8 +13,8 @@ interface MathRepresentation { interface VectorRepresentation extends MathRepresentation { setScalar(s: number): any } -type MathProps = { - [K in keyof T]: T[K] extends infer M +type MathProps

= { + [K in keyof P]: P[K] extends infer M ? M extends THREE.Color ? ConstructorParameters | THREE.ColorRepresentation : M extends MathRepresentation @@ -28,20 +28,18 @@ type MathProps = { interface RaycastableRepresentation { raycast(raycaster: THREE.Raycaster, intersects: THREE.Intersection[]): void } -type EventProps = T extends RaycastableRepresentation ? EventHandlers : {} +type EventProps

= P extends RaycastableRepresentation ? Partial : {} -interface ReactProps { +interface ReactProps

{ children?: React.ReactNode - ref?: React.Ref + ref?: React.Ref

key?: React.Key } -type NodeProps = InstanceProps & +type NodeProps = Omit, 'object'> & Partial & MathProps

& EventProps

> -export type Node = Mutable< - Overwrite>, Omit, 'object'>> -> +export type Node = Mutable>, NodeProps>> type ThreeExports = typeof THREE type ThreeElementsImpl = { @@ -51,7 +49,7 @@ type ThreeElementsImpl = { } export interface ThreeElements extends ThreeElementsImpl { - primitive: Omit, 'args'> + primitive: Omit, 'args'> & { object: any } } declare global { From 02af69a8af5500c4d37c2ceffe86a734aba61741 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Tue, 30 Aug 2022 04:13:20 -0500 Subject: [PATCH 16/58] chore(docs): update onUpdate, TS sections --- docs/API/events.mdx | 3 --- docs/tutorials/events-and-interaction.mdx | 1 - docs/tutorials/typescript.mdx | 18 +++--------------- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/docs/API/events.mdx b/docs/API/events.mdx index 977652e7fa..20a9c36fc3 100644 --- a/docs/API/events.mdx +++ b/docs/API/events.mdx @@ -6,8 +6,6 @@ nav: 8 `three.js` objects that implement their own `raycast` method (meshes, lines, etc) can be interacted with by declaring events on them. We support pointer events, clicks and wheel-scroll. Events contain the browser event as well as the `three.js` event data (object, point, distance, etc). You may want to [polyfill](https://github.com/jquery/PEP) them, if that's a concern. -Additionally, there's a special `onUpdate` that is called every time the object gets fresh props, which is good for things like `self => (self.verticesNeedUpdate = true)`. - Also notice the `onPointerMissed` on the canvas element, which fires on clicks that haven't hit _any_ meshes. ```jsx @@ -24,7 +22,6 @@ Also notice the `onPointerMissed` on the canvas element, which fires on clicks t onPointerLeave={(e) => console.log('leave')} // see note 1 onPointerMove={(e) => console.log('move')} onPointerMissed={() => console.log('missed')} - onUpdate={(self) => console.log('props have been updated')} /> ``` diff --git a/docs/tutorials/events-and-interaction.mdx b/docs/tutorials/events-and-interaction.mdx index a73fac93ac..bed3280f88 100644 --- a/docs/tutorials/events-and-interaction.mdx +++ b/docs/tutorials/events-and-interaction.mdx @@ -26,7 +26,6 @@ Any mesh in React Three Fiber has a large number of events, 13 to be more precis onPointerLeave={(e) => console.log('leave')} onPointerMove={(e) => console.log('move')} onPointerMissed={() => console.log('missed')} - onUpdate={(self) => console.log('props have been updated')} /> ``` diff --git a/docs/tutorials/typescript.mdx b/docs/tutorials/typescript.mdx index 4c57ca7bb2..779289facc 100644 --- a/docs/tutorials/typescript.mdx +++ b/docs/tutorials/typescript.mdx @@ -89,26 +89,14 @@ You can then declaratively create custom elements with primitives, but TypeScrip ``` -### Node Helpers - -react-three-fiber exports helpers that you can use to define different types of nodes. These nodes will type an element that we'll attach to the global JSX namespace. - -```tsx -Node -Object3DNode -BufferGeometryNode -MaterialNode -LightNode -``` - ### Extending ThreeElements -Since our custom element is an object, we'll use `Object3DNode` to define it. +To define our element in JSX, we'll use the `Node` interface to extend `ThreeElements`. This interface describes three.js classes that are available in the R3F catalog and can be used as native elements. ```tsx import { useRef, useEffect } from 'react' import { GridHelper } from 'three' -import { extend, Object3DNode } from '@react-three/fiber' +import { extend, Node } from '@react-three/fiber' // Create our custom element class CustomElement extends GridHelper {} @@ -119,7 +107,7 @@ extend({ CustomElement }) // Add types to ThreeElements elements so primitives pick up on it declare module '@react-three/fiber' { interface ThreeElements { - customElement: Object3DNode + customElement: Node } } From 903392ebafdac57f4e1188e493ab64123858c6f3 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Tue, 30 Aug 2022 05:41:32 -0500 Subject: [PATCH 17/58] chore(types): cleanup --- packages/fiber/src/core/utils.ts | 4 ++-- packages/fiber/src/three-types.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index a4bfcbf9e9..cff01cd973 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -57,7 +57,7 @@ export class ErrorBoundary extends React.Component< export type ClassConstructor = { new (): void } -export type ObjectMap = { +export interface ObjectMap { nodes: { [name: string]: THREE.Object3D } materials: { [name: string]: THREE.Material } } @@ -72,7 +72,7 @@ export function calculateDpr(dpr: Dpr): number { export const getRootState = (obj: T): RootState | undefined => (obj as Instance['object']).__r3f?.root.getState() -export type EquConfig = { +export interface EquConfig { /** Compare arrays by reference equality a === b (default), or by shallow equality */ arrays?: 'reference' | 'shallow' /** Compare objects by reference equality a === b (default), or by shallow equality */ diff --git a/packages/fiber/src/three-types.ts b/packages/fiber/src/three-types.ts index 35b5b4fdbc..d660d1eae2 100644 --- a/packages/fiber/src/three-types.ts +++ b/packages/fiber/src/three-types.ts @@ -22,7 +22,7 @@ type MathProps

= { ? M | Parameters | Parameters[0] : M | Parameters : {} - : never + : {} } interface RaycastableRepresentation { @@ -36,7 +36,7 @@ interface ReactProps

{ key?: React.Key } -type NodeProps = Omit, 'object'> & +type NodeProps = Omit, 'object'> & Partial & MathProps

& EventProps

> export type Node = Mutable>, NodeProps>> From cb1c6b9a59e49cc5ba8d9fdbd3a681b8840786cd Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Wed, 31 Aug 2022 00:16:02 -0500 Subject: [PATCH 18/58] fix(types): ensure instanceprops override nodeprops --- packages/fiber/src/three-types.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/fiber/src/three-types.ts b/packages/fiber/src/three-types.ts index d660d1eae2..a4f30be953 100644 --- a/packages/fiber/src/three-types.ts +++ b/packages/fiber/src/three-types.ts @@ -36,10 +36,11 @@ interface ReactProps

{ key?: React.Key } -type NodeProps = Omit, 'object'> & - Partial & MathProps

& EventProps

> +type NodeProps = Partial< + Overwrite, ReactProps

& MathProps

& EventProps

> +> -export type Node = Mutable>, NodeProps>> +export type Node = Mutable, Omit, 'object'>>> type ThreeExports = typeof THREE type ThreeElementsImpl = { From 90fefb99d11fb095778f59099ca0b2643f64d34c Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Wed, 31 Aug 2022 00:42:31 -0500 Subject: [PATCH 19/58] fix(types): don't exclude functions from JSX --- packages/fiber/src/three-types.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/fiber/src/three-types.ts b/packages/fiber/src/three-types.ts index a4f30be953..c9386e2399 100644 --- a/packages/fiber/src/three-types.ts +++ b/packages/fiber/src/three-types.ts @@ -4,7 +4,6 @@ import type { InstanceProps, ConstructorRepresentation } from './core/renderer' type Mutable

= { [K in keyof P]: P[K] | Readonly } type NonFunctionKeys

= { [K in keyof P]: P[K] extends Function ? never : K }[keyof P] -type WithoutFunctions

= Pick> type Overwrite = Omit> & O interface MathRepresentation { @@ -37,7 +36,7 @@ interface ReactProps

{ } type NodeProps = Partial< - Overwrite, ReactProps

& MathProps

& EventProps

> + Overwrite & MathProps

& EventProps

> > export type Node = Mutable, Omit, 'object'>>> From f3c13208a07cbc22123b72cae6d7180862d143d7 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 1 Sep 2022 02:18:56 -0500 Subject: [PATCH 20/58] fix: unlink instances on unmount, null-check unmounted containers --- packages/fiber/src/core/renderer.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index 8df8aebd9a..3b87a0dfe9 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -176,6 +176,9 @@ function removeChild( // attached to them declaratively ... if (!isPrimitive && recursive) removeRecursive(child.children, child, shouldDispose) + // Unlink instance object + delete child.object.__r3f + // Dispose object whenever the reconciler feels like it if (child.object.type !== 'Scene' && shouldDispose) { const dispose = child.object.dispose @@ -269,17 +272,22 @@ const reconciler = Reconciler< appendInitialChild: appendChild, insertBefore, appendChildToContainer(container, child) { - if (!child) return - const scene = (container.getState().scene as unknown as Instance['object']).__r3f! + const scene = (container.getState().scene as unknown as Instance['object']).__r3f + if (!child || !scene) return + appendChild(scene, child) }, removeChildFromContainer(container, child) { - if (!child) return - removeChild((container.getState().scene as unknown as Instance['object']).__r3f!, child) + const scene = (container.getState().scene as unknown as Instance['object']).__r3f + if (!child || !scene) return + + removeChild(scene, child) }, insertInContainerBefore(container, child, beforeChild) { - if (!child || !beforeChild) return - insertBefore((container.getState().scene as unknown as Instance['object']).__r3f!, child, beforeChild) + const scene = (container.getState().scene as unknown as Instance['object']).__r3f + if (!child || !beforeChild || !scene) return + + insertBefore(scene, child, beforeChild) }, getRootHostContext: () => null, getChildHostContext: (parentHostContext) => parentHostContext, From d4153834e40a787ad46d787fe64dc0c04d86be43 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 1 Sep 2022 05:32:06 -0500 Subject: [PATCH 21/58] refactor(RTTR): use Instance type, drop `is` --- .../test-renderer/src/__tests__/is.test.ts | 82 ------------------- .../test-renderer/src/createTestInstance.ts | 37 ++++----- packages/test-renderer/src/fireEvent.ts | 12 ++- packages/test-renderer/src/helpers/graph.ts | 4 +- packages/test-renderer/src/helpers/is.ts | 21 ----- .../test-renderer/src/helpers/testInstance.ts | 2 +- packages/test-renderer/src/helpers/tree.ts | 6 +- packages/test-renderer/src/index.tsx | 6 +- packages/test-renderer/src/types/internal.ts | 12 --- 9 files changed, 31 insertions(+), 151 deletions(-) delete mode 100644 packages/test-renderer/src/__tests__/is.test.ts delete mode 100644 packages/test-renderer/src/helpers/is.ts diff --git a/packages/test-renderer/src/__tests__/is.test.ts b/packages/test-renderer/src/__tests__/is.test.ts deleted file mode 100644 index ce3ea3f0ba..0000000000 --- a/packages/test-renderer/src/__tests__/is.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { is } from '../helpers/is' - -describe('is', () => { - const myFunc = () => { - return null - } - const myObj = { - myProp: 'test-prop', - } - const myStr = 'test-string' - const myNum = 1 - const myUnd = undefined - const myArr = [1, 2, 3] - - it('should tell me if something IS a function', () => { - expect(is.fun(myFunc)).toBe(true) - - expect(is.fun(myObj)).toBe(false) - expect(is.fun(myStr)).toBe(false) - expect(is.fun(myNum)).toBe(false) - expect(is.fun(myUnd)).toBe(false) - expect(is.fun(myArr)).toBe(false) - }) - it('should tell me if something IS an object', () => { - expect(is.obj(myFunc)).toBe(false) - - expect(is.obj(myObj)).toBe(true) - - expect(is.obj(myStr)).toBe(false) - expect(is.obj(myNum)).toBe(false) - expect(is.obj(myUnd)).toBe(false) - expect(is.obj(myArr)).toBe(false) - }) - it('should tell me if something IS a string', () => { - expect(is.str(myFunc)).toBe(false) - expect(is.str(myObj)).toBe(false) - - expect(is.str(myStr)).toBe(true) - - expect(is.str(myNum)).toBe(false) - expect(is.str(myUnd)).toBe(false) - expect(is.str(myArr)).toBe(false) - }) - it('should tell me if something IS a number', () => { - expect(is.num(myFunc)).toBe(false) - expect(is.num(myObj)).toBe(false) - expect(is.num(myStr)).toBe(false) - - expect(is.num(myNum)).toBe(true) - - expect(is.num(myUnd)).toBe(false) - expect(is.num(myArr)).toBe(false) - }) - it('should tell me if something IS undefined', () => { - expect(is.und(myFunc)).toBe(false) - expect(is.und(myObj)).toBe(false) - expect(is.und(myStr)).toBe(false) - expect(is.und(myNum)).toBe(false) - - expect(is.und(myUnd)).toBe(true) - - expect(is.und(myArr)).toBe(false) - }) - it('should tell me if something is an array', () => { - expect(is.arr(myFunc)).toBe(false) - expect(is.arr(myObj)).toBe(false) - expect(is.arr(myStr)).toBe(false) - expect(is.arr(myNum)).toBe(false) - expect(is.arr(myUnd)).toBe(false) - - expect(is.arr(myArr)).toBe(true) - }) - it('should tell me if something is equal', () => { - expect(is.equ([], '')).toBe(false) - - expect(is.equ('hello', 'hello')).toBe(true) - expect(is.equ(1, 1)).toBe(true) - expect(is.equ(myObj, myObj)).toBe(true) - expect(is.equ(myArr, myArr)).toBe(true) - expect(is.equ([1, 2, 3], [1, 2, 3])).toBe(true) - }) -}) diff --git a/packages/test-renderer/src/createTestInstance.ts b/packages/test-renderer/src/createTestInstance.ts index b0ed075206..d47cdf6d09 100644 --- a/packages/test-renderer/src/createTestInstance.ts +++ b/packages/test-renderer/src/createTestInstance.ts @@ -1,17 +1,18 @@ -import { Object3D } from 'three' +import type * as THREE from 'three' +import type { Instance } from '@react-three/fiber' -import type { MockInstance, Obj, TestInstanceChildOpts } from './types/internal' +import type { Obj, TestInstanceChildOpts } from './types/internal' import { expectOne, matchProps, findAll } from './helpers/testInstance' -export class ReactThreeTestInstance { - _fiber: MockInstance +export class ReactThreeTestInstance { + _fiber: Instance - constructor(fiber: MockInstance) { + constructor(fiber: Instance) { this._fiber = fiber } - public get fiber(): MockInstance { + public get fiber(): Instance { return this._fiber } @@ -44,37 +45,35 @@ export class ReactThreeTestInstance { } private getChildren = ( - fiber: MockInstance, + fiber: Instance, opts: TestInstanceChildOpts = { exhaustive: false }, ): 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()}`) + fiber.children.filter((child) => !child.props.attach || opts.exhaustive).map((fib) => wrapFiber(fib as Instance)) public findAll = (decider: (node: ReactThreeTestInstance) => boolean): ReactThreeTestInstance[] => - findAll(this, decider) + findAll(this as unknown as ReactThreeTestInstance, decider) + + public find = (decider: (node: ReactThreeTestInstance) => boolean): ReactThreeTestInstance => + expectOne(this.findAll(decider), `matching custom checker: ${decider.toString()}`) public findByType = (type: string): ReactThreeTestInstance => expectOne( - findAll(this, (node) => Boolean(node.type && node.type === type)), + this.findAll((node) => Boolean(node.type && node.type === type)), `with node type: "${type || 'Unknown'}"`, ) public findAllByType = (type: string): ReactThreeTestInstance[] => - findAll(this, (node) => Boolean(node.type && node.type === type)) + this.findAll((node) => Boolean(node.type && node.type === type)) public findByProps = (props: Obj): ReactThreeTestInstance => expectOne(this.findAllByProps(props), `with props: ${JSON.stringify(props)}`) public findAllByProps = (props: Obj): ReactThreeTestInstance[] => - findAll(this, (node: ReactThreeTestInstance) => Boolean(node.props && matchProps(node.props, props))) + this.findAll((node: ReactThreeTestInstance) => Boolean(node.props && matchProps(node.props, props))) } -const fiberToWrapper = new WeakMap() -export const wrapFiber = (fiber: MockInstance): ReactThreeTestInstance => { +const fiberToWrapper = new WeakMap() +export const wrapFiber = (fiber: Instance): ReactThreeTestInstance => { let wrapper = fiberToWrapper.get(fiber) if (wrapper === undefined) { wrapper = new ReactThreeTestInstance(fiber) diff --git a/packages/test-renderer/src/fireEvent.ts b/packages/test-renderer/src/fireEvent.ts index 8f0fd3dd27..df192b1d0e 100644 --- a/packages/test-renderer/src/fireEvent.ts +++ b/packages/test-renderer/src/fireEvent.ts @@ -1,16 +1,14 @@ -import ReactReconciler from 'react-reconciler' +import type { UseBoundStore } from 'zustand' +import type { RootState } from '@react-three/fiber' import { toEventHandlerName } from './helpers/strings' import { ReactThreeTestInstance } from './createTestInstance' -import type { MockSyntheticEvent } from './types/public' -import type { MockUseStoreState, MockEventData } from './types/internal' +import type { Act, MockSyntheticEvent } from './types/public' +import type { MockEventData } from './types/internal' -export const createEventFirer = ( - act: ReactReconciler.Reconciler['act'], - store: MockUseStoreState, -) => { +export const createEventFirer = (act: Act, store: UseBoundStore) => { const findEventHandler = ( element: ReactThreeTestInstance, eventName: string, diff --git a/packages/test-renderer/src/helpers/graph.ts b/packages/test-renderer/src/helpers/graph.ts index 7ab0da38cf..36359ebec5 100644 --- a/packages/test-renderer/src/helpers/graph.ts +++ b/packages/test-renderer/src/helpers/graph.ts @@ -1,4 +1,4 @@ -import type { MockInstance } from '../types/internal' +import type { Instance } from '@react-three/fiber' import type { SceneGraphItem } from '../types/public' const graphObjectFactory = ( @@ -11,5 +11,5 @@ const graphObjectFactory = ( children, }) -export const toGraph = (object: MockInstance): SceneGraphItem[] => +export const toGraph = (object: Instance): SceneGraphItem[] => object.children.map((child) => graphObjectFactory(child.object.type, child.object.name ?? '', toGraph(child))) diff --git a/packages/test-renderer/src/helpers/is.ts b/packages/test-renderer/src/helpers/is.ts deleted file mode 100644 index 4733da0c59..0000000000 --- a/packages/test-renderer/src/helpers/is.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const is = { - obj: (a: any) => a === Object(a) && !is.arr(a) && typeof a !== 'function', - fun: (a: any) => typeof a === 'function', - str: (a: any) => typeof a === 'string', - num: (a: any) => typeof a === 'number', - und: (a: any) => a === void 0, - arr: (a: any) => Array.isArray(a), - equ(a: any, b: any) { - // Wrong type or one of the two undefined, doesn't match - if (typeof a !== typeof b || !!a !== !!b) return false - // Atomic, just compare a against b - if (is.str(a) || is.num(a) || is.obj(a)) return a === b - // Array, shallow compare first to see if it's a match - if (is.arr(a) && a == b) return true - // Last resort, go through keys - let i - for (i in a) if (!(i in b)) return false - for (i in b) if (a[i] !== b[i]) return false - return is.und(i) ? a === b : true - }, -} diff --git a/packages/test-renderer/src/helpers/testInstance.ts b/packages/test-renderer/src/helpers/testInstance.ts index 0b7ba964ed..cb75990666 100644 --- a/packages/test-renderer/src/helpers/testInstance.ts +++ b/packages/test-renderer/src/helpers/testInstance.ts @@ -1,4 +1,4 @@ -import { ReactThreeTestInstance } from '../createTestInstance' +import type { ReactThreeTestInstance } from '../createTestInstance' import type { Obj } from '../types/internal' export const expectOne = (items: TItem[], msg: string) => { diff --git a/packages/test-renderer/src/helpers/tree.ts b/packages/test-renderer/src/helpers/tree.ts index 45ca6890a6..46095bda3f 100644 --- a/packages/test-renderer/src/helpers/tree.ts +++ b/packages/test-renderer/src/helpers/tree.ts @@ -1,5 +1,5 @@ +import type { Instance } from '@react-three/fiber' import type { TreeNode, Tree } from '../types/public' -import type { MockInstance } from '../types/internal' import { lowerCaseFirstLetter } from './strings' const treeObjectFactory = ( @@ -12,7 +12,7 @@ const treeObjectFactory = ( children, }) -const toTreeBranch = (children: MockInstance[]): TreeNode[] => +const toTreeBranch = (children: Instance[]): TreeNode[] => children.map((child) => { return treeObjectFactory( lowerCaseFirstLetter(child.object.type || child.object.constructor.name), @@ -21,4 +21,4 @@ const toTreeBranch = (children: MockInstance[]): TreeNode[] => ) }) -export const toTree = (root: MockInstance): Tree => toTreeBranch(root.children) +export const toTree = (root: Instance): Tree => toTreeBranch(root.children) diff --git a/packages/test-renderer/src/index.tsx b/packages/test-renderer/src/index.tsx index 86b0c2d720..8217632491 100644 --- a/packages/test-renderer/src/index.tsx +++ b/packages/test-renderer/src/index.tsx @@ -5,13 +5,11 @@ import { extend, _roots as mockRoots, createRoot, reconciler, act as _act } from import { toTree } from './helpers/tree' import { toGraph } from './helpers/graph' -import { is } from './helpers/is' import { createCanvas } from './createTestCanvas' import { createWebGLContext } from './createWebGLContext' import { createEventFirer } from './fireEvent' -import type { MockInstance } from './types/internal' import type { CreateOptions, Renderer, Act } from './types/public' import { wrapFiber } from './createTestInstance' @@ -50,7 +48,7 @@ const create = async (element: React.ReactNode, options?: Partial const _store = mockRoots.get(canvas)!.store await act(async () => _root.render(element)) - const _scene = (_store.getState().scene as any).__r3f as MockInstance + const _scene = (_store.getState().scene as any).__r3f return { scene: wrapFiber(_scene), @@ -92,7 +90,7 @@ const create = async (element: React.ReactNode, options?: Partial storeSubscribers.forEach((subscriber) => { for (let i = 0; i < frames; i++) { - if (is.arr(delta)) { + if (Array.isArray(delta)) { promises.push( new Promise(() => subscriber.ref.current(state, (delta as number[])[i] || (delta as number[])[-1])), ) diff --git a/packages/test-renderer/src/types/internal.ts b/packages/test-renderer/src/types/internal.ts index debaa16d52..ab1a89a13a 100644 --- a/packages/test-renderer/src/types/internal.ts +++ b/packages/test-renderer/src/types/internal.ts @@ -1,15 +1,3 @@ -import { UseBoundStore } from 'zustand' -import type { Instance, RootState } from '@react-three/fiber' - -export type MockUseStoreState = UseBoundStore - -export interface MockInstance extends Omit { - root: MockUseStoreState - parent: MockInstance - children: MockInstance[] - object: O -} - export type CreateCanvasParameters = { beforeReturn?: (canvas: HTMLCanvasElement) => void width?: number From 6425146baa7630f8a832aff5100ce2e4b095b5b0 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 1 Sep 2022 05:35:00 -0500 Subject: [PATCH 22/58] chore(RTTR): remove any cast --- packages/test-renderer/src/__tests__/RTTR.core.test.tsx | 2 +- packages/test-renderer/src/index.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/test-renderer/src/__tests__/RTTR.core.test.tsx b/packages/test-renderer/src/__tests__/RTTR.core.test.tsx index 466073479c..012b50b376 100644 --- a/packages/test-renderer/src/__tests__/RTTR.core.test.tsx +++ b/packages/test-renderer/src/__tests__/RTTR.core.test.tsx @@ -29,7 +29,7 @@ describe('ReactThreeTestRenderer Core', () => { const [name, setName] = React.useState() React.useLayoutEffect(() => { - ;(React as any).startTransition(() => void setName('mesh')) + React.startTransition(() => void setName('mesh')) }) return ( diff --git a/packages/test-renderer/src/index.tsx b/packages/test-renderer/src/index.tsx index 8217632491..d2e8d28ae6 100644 --- a/packages/test-renderer/src/index.tsx +++ b/packages/test-renderer/src/index.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import * as THREE from 'three' -import { extend, _roots as mockRoots, createRoot, reconciler, act as _act } from '@react-three/fiber' +import { extend, _roots as mockRoots, createRoot, reconciler, act as _act, Instance } from '@react-three/fiber' import { toTree } from './helpers/tree' import { toGraph } from './helpers/graph' @@ -48,7 +48,7 @@ const create = async (element: React.ReactNode, options?: Partial const _store = mockRoots.get(canvas)!.store await act(async () => _root.render(element)) - const _scene = (_store.getState().scene as any).__r3f + const _scene = (_store.getState().scene as Instance['object']).__r3f! return { scene: wrapFiber(_scene), From b59c9c8a521c3ba66661273f69b6a4b0a7c6d181 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Thu, 1 Sep 2022 18:41:34 -0500 Subject: [PATCH 23/58] chore(types): prefer InstanceType, node must extend constructor signature --- packages/fiber/src/three-types.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/fiber/src/three-types.ts b/packages/fiber/src/three-types.ts index dd8276a1cd..891be79501 100644 --- a/packages/fiber/src/three-types.ts +++ b/packages/fiber/src/three-types.ts @@ -35,11 +35,13 @@ interface ReactProps

{ key?: React.Key } -type NodeProps = Partial< +type NodeProps> = Partial< Overwrite & MathProps

& EventProps

> > -export type Node = Mutable, Omit, 'object'>>> +export type Node = Mutable< + Overwrite, Omit, 'object'>> +> type ThreeExports = typeof THREE type ThreeElementsImpl = { From 830c101ec9243a44ebaac65ed5d1958bbed4dc14 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 2 Sep 2022 03:18:30 -0500 Subject: [PATCH 24/58] fix(types): allow unknown props in instanceprops --- packages/fiber/src/core/renderer.ts | 14 +++++++------- packages/fiber/src/core/utils.ts | 28 ++++++++++++++++------------ 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index 3b87a0dfe9..d42cf40ad2 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -22,7 +22,7 @@ export interface Catalogue { export type Args = T extends ConstructorRepresentation ? ConstructorParameters : any[] -export type InstanceProps = { +export interface InstanceProps { args?: Args object?: T visible?: boolean @@ -35,7 +35,7 @@ export interface Instance { type: string parent: Instance | null children: Instance[] - props: InstanceProps + props: InstanceProps & Record object: O & { __r3f?: Instance } eventCount: number handlers: Partial @@ -45,7 +45,7 @@ export interface Instance { interface HostConfig { type: string - props: InstanceProps + props: Instance['props'] container: UseBoundStore instance: Instance textInstance: void @@ -53,7 +53,7 @@ interface HostConfig { hydratableInstance: never publicInstance: Instance['object'] hostContext: never - updatePayload: null | [true] | [false, InstanceProps] + updatePayload: null | [true] | [false, Instance['props']] childSet: never timeoutHandle: number | undefined noTimeout: -1 @@ -157,7 +157,7 @@ function removeChild( detach(parent, child) } else if (child.object instanceof THREE.Object3D && parent.object instanceof THREE.Object3D) { parent.object.remove(child.object) - removeInteractivity(child.root, child.object as unknown as THREE.Object3D) + removeInteractivity(child.root, child.object) } // Allow objects to bail out of recursive dispose altogether by passing dispose={null} @@ -231,8 +231,8 @@ function switchInstance( if (fiber !== null) { fiber.stateNode = newInstance if (fiber.ref) { - if (typeof fiber.ref === 'function') (fiber as unknown as any).ref(newInstance.object) - else (fiber.ref as Reconciler.RefObject).current = newInstance.object + if (typeof fiber.ref === 'function') fiber.ref(newInstance.object) + else fiber.ref.current = newInstance.object } } }) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index cff01cd973..3012cb7ea3 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -4,7 +4,7 @@ import type { Fiber } from 'react-reconciler' import type { UseBoundStore } from 'zustand' import type { EventHandlers } from './events' import type { Dpr, RootState, Size } from './store' -import type { Instance, InstanceProps } from './renderer' +import type { Instance } from './renderer' export type Camera = THREE.OrthographicCamera | THREE.PerspectiveCamera export const isOrthographicCamera = (def: Camera): def is THREE.OrthographicCamera => @@ -143,14 +143,14 @@ export function dispose(obj: T): void { const REACT_INTERNAL_PROPS = ['children', 'key', 'ref'] // Gets only instance props from reconciler fibers -function getInstanceProps(queue: Fiber['pendingProps']): InstanceProps { - const props: Record = {} +function getInstanceProps(queue: Fiber['pendingProps']): Instance['props'] { + const props: Instance['props'] = {} for (const key in queue) { if (!REACT_INTERNAL_PROPS.includes(key)) props[key] = queue[key] } - return props as InstanceProps + return props } // Each object in the scene carries a small LocalState descriptor @@ -158,7 +158,7 @@ export function prepare( target: T, root: UseBoundStore, type: string, - props: InstanceProps, + props: Instance['props'], ): Instance { const object = target as unknown as Instance['object'] @@ -244,18 +244,22 @@ const RESERVED_PROPS = [ ] // This function prepares a set of changes to be applied to the instance -export function diffProps(newProps: InstanceProps, oldProps: InstanceProps, remove = false): InstanceProps { - const changedProps: Record = {} +export function diffProps( + newProps: Instance['props'], + oldProps: Instance['props'], + remove = false, +): Instance['props'] { + const changedProps: Instance['props'] = {} // 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 as Record)[key], (oldProps as Record)[key])) continue + if (is.equ(newProps[key], oldProps[key])) continue // Props changed, add them - changedProps[key] = (newProps as Record)[key] + changedProps[key] = newProps[key] } // Catch removed props, prepend them so they can be reset or removed @@ -266,17 +270,17 @@ export function diffProps(newProps: InstanceProps, oldProps: InstanceProps, remo } } - return changedProps as InstanceProps + return changedProps } // This function applies a set of changes to the instance -export function applyProps(object: Instance['object'], props: InstanceProps): Instance['object'] { +export function applyProps(object: Instance['object'], props: Instance['props']): Instance['object'] { const instance = object.__r3f const rootState = instance?.root.getState() const prevHandlers = instance?.eventCount for (const prop in props) { - let value = (props as Record)[prop] + let value = props[prop] // Don't mutate reserved keys if (RESERVED_PROPS.includes(prop)) continue From ec08f4a2e8c9256e4bd9138b47b6aa712550d852 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 2 Sep 2022 17:57:19 -0500 Subject: [PATCH 25/58] fix(types): pass prototype to instance props --- packages/fiber/src/three-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiber/src/three-types.ts b/packages/fiber/src/three-types.ts index 891be79501..11cc33dfb2 100644 --- a/packages/fiber/src/three-types.ts +++ b/packages/fiber/src/three-types.ts @@ -40,7 +40,7 @@ type NodeProps> = Parti > export type Node = Mutable< - Overwrite, Omit, 'object'>> + Overwrite, Omit>, 'object'>> > type ThreeExports = typeof THREE From bcf145035c2e70b2ec15e1df5d94b5171a1ff4da Mon Sep 17 00:00:00 2001 From: Cody Bennett Date: Fri, 2 Sep 2022 18:35:24 -0500 Subject: [PATCH 26/58] [v9] fix: handle container effects on completed trees, track instance visibility (#2483) --- packages/fiber/src/core/renderer.ts | 97 +++++++++++++++------ packages/fiber/src/core/utils.ts | 1 + packages/fiber/tests/core/renderer.test.tsx | 45 ++++++++++ 3 files changed, 114 insertions(+), 29 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index d42cf40ad2..b27e409775 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -41,6 +41,7 @@ export interface Instance { handlers: Partial attach?: AttachType previousAttach?: any + isHidden: boolean } interface HostConfig { @@ -67,19 +68,23 @@ function createInstance( props: HostConfig['props'], root: UseBoundStore, ): HostConfig['instance'] { + // Get target from catalogue const name = `${type[0].toUpperCase()}${type.slice(1)}` const target = catalogue[name] + // Validate element target 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`, ) + // Validate primitives if (type === 'primitive' && !props.object) throw new Error(`R3F: Primitives without 'object' are invalid!`) // Throw if an object or literal was passed for args if (props.args !== undefined && !Array.isArray(props.args)) throw new Error('R3F: The args prop must be an array!') + // Create instance const object = props.object ?? new target(...(props.args ?? [])) const instance = prepare(object, root, type, props) @@ -95,18 +100,40 @@ function createInstance( return instance } +// https://github.com/facebook/react/issues/20271 +// This will make sure events and attach are only handled once when trees are complete +function handleContainerEffects(parent: Instance, child: Instance) { + // Bail if tree isn't mounted or parent is not a container. + // This ensures that the tree is finalized and React won't discard results to Suspense + const state = child.root.getState() + if (!parent.parent && parent.object !== state.scene) return + + // Handle interactivity + if (child.eventCount > 0 && child.object.raycast !== null && child.object instanceof THREE.Object3D) { + state.internal.interaction.push(child.object) + } + + // Handle attach + if (child.props.attach) attach(parent, child) + for (const childInstance of child.children) handleContainerEffects(child, childInstance) +} + function appendChild(parent: HostConfig['instance'], child: HostConfig['instance'] | HostConfig['textInstance']) { if (!child) return + // Link instances child.parent = parent parent.children.push(child) - if (child.props.attach) { - attach(parent, child) - } else if (parent.object instanceof THREE.Object3D && child.object instanceof THREE.Object3D) { + // Add Object3Ds if able + if (!child.props.attach && parent.object instanceof THREE.Object3D && child.object instanceof THREE.Object3D) { parent.object.add(child.object) } + // Attach tree once complete + handleContainerEffects(parent, child) + + // Tree was updated, request a frame invalidateInstance(child) } @@ -117,12 +144,13 @@ function insertBefore( ) { if (!child || !beforeChild) return + // Link instances child.parent = parent parent.children.splice(parent.children.indexOf(beforeChild), 0, child) - if (child.props.attach) { - attach(parent, child) - } else if ( + // Manually splice Object3Ds + if ( + !child.props.attach && parent.object instanceof THREE.Object3D && child.object instanceof THREE.Object3D && beforeChild.object instanceof THREE.Object3D @@ -132,12 +160,21 @@ function insertBefore( child.object.dispatchEvent({ type: 'added' }) } + // Attach tree once complete + handleContainerEffects(parent, child) + + // Tree was updated, request a frame invalidateInstance(child) } -function removeRecursive(children: HostConfig['instance'][], parent: HostConfig['instance'], dispose: boolean = false) { +function removeRecursive( + children: HostConfig['instance'][], + parent: HostConfig['instance'], + recursive: boolean = false, + dispose: boolean = false, +) { for (const child of children) { - removeChild(parent, child, dispose) + removeChild(parent, child, recursive, dispose) } } @@ -149,10 +186,12 @@ function removeChild( ) { if (!child) return + // Unlink instances child.parent = null const childIndex = parent.children.indexOf(child) if (childIndex !== -1) parent.children.splice(childIndex, 1) + // Eagerly tear down tree if (child.props.attach) { detach(parent, child) } else if (child.object instanceof THREE.Object3D && parent.object instanceof THREE.Object3D) { @@ -174,7 +213,7 @@ function removeChild( // Remove nested child objects. Primitives should not have objects and children that are // attached to them declaratively ... - if (!isPrimitive && recursive) removeRecursive(child.children, child, shouldDispose) + if (!isPrimitive && recursive) removeRecursive(child.children, child, recursive, shouldDispose) // Unlink instance object delete child.object.__r3f @@ -193,6 +232,7 @@ function removeChild( } } + // Tree was updated, request a frame for top-level instance if (dispose === undefined) invalidateInstance(child) } @@ -205,18 +245,19 @@ function switchInstance( // Create a new instance const newInstance = createInstance(type, props, oldInstance.root) - // Move children to new instance - for (const child of oldInstance.children) { - appendChild(newInstance, child) - } - oldInstance.children = [] - // Link up new instance const parent = oldInstance.parent if (parent) { - appendChild(parent, newInstance) removeChild(parent, oldInstance, false) + appendChild(parent, newInstance) + } + + // Move children to new instance + for (const child of oldInstance.children) { + removeChild(oldInstance, child, false, false) + appendChild(newInstance, child) } + oldInstance.children = [] // Re-bind event handlers if (newInstance.object.raycast !== null && newInstance.object instanceof THREE.Object3D && newInstance.eventCount) { @@ -237,6 +278,7 @@ function switchInstance( } }) + // Tree was updated, request a frame invalidateInstance(newInstance) return newInstance @@ -316,15 +358,8 @@ const reconciler = Reconciler< Object.assign(instance.props, changedProps) applyProps(instance.object, changedProps) }, - // https://github.com/facebook/react/issues/20271 - // This will make sure events are only added once to the central container - finalizeInitialChildren: (instance) => instance.eventCount > 0, - commitMount(instance) { - if (instance.object.raycast !== null && instance.object instanceof THREE.Object3D && instance.eventCount) { - const rootState = instance.root.getState() - rootState.internal.interaction.push(instance.object) - } - }, + finalizeInitialChildren: () => false, + commitMount() {}, getPublicInstance: (instance) => instance?.object!, prepareForCommit: () => null, preparePortalMount: (container) => prepare(container.getState().scene, container, '', {}), @@ -338,15 +373,19 @@ const reconciler = Reconciler< instance.object.visible = false } + instance.isHidden = true invalidateInstance(instance) }, unhideInstance(instance) { - if (instance.props.attach && instance.parent?.object) { - attach(instance.parent, instance) - } else if (instance.object instanceof THREE.Object3D && instance.props.visible !== false) { - instance.object.visible = true + if (instance.isHidden) { + if (instance.props.attach && instance.parent?.object) { + attach(instance.parent, instance) + } else if (instance.object instanceof THREE.Object3D && instance.props.visible !== false) { + instance.object.visible = true + } } + instance.isHidden = false invalidateInstance(instance) }, createTextInstance: handleTextInstance, diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 3012cb7ea3..5539ad4d9b 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -174,6 +174,7 @@ export function prepare( object, eventCount: 0, handlers: {}, + isHidden: false, } object.__r3f = instance } diff --git a/packages/fiber/tests/core/renderer.test.tsx b/packages/fiber/tests/core/renderer.test.tsx index 9f0983593c..ca85372a73 100644 --- a/packages/fiber/tests/core/renderer.test.tsx +++ b/packages/fiber/tests/core/renderer.test.tsx @@ -788,4 +788,49 @@ describe('renderer', () => { expect(console.warn).toHaveBeenCalled() console.warn = warn }) + + it('should gracefully interrupt when building up the tree', async () => { + const calls: string[] = [] + let lastAttached!: string | undefined + let lastMounted!: string | undefined + + function SuspenseComponent({ reconstruct = false }: { reconstruct?: boolean }) { + suspend(async (reconstruct) => reconstruct, [reconstruct]) + + return ( + + void (lastMounted = self?.uuid)} + attach={(parent, self) => { + calls.push('attach') + lastAttached = self.uuid + return () => calls.push('detach') + }} + /> + + ) + } + + function Test(props: { reconstruct?: boolean }) { + React.useLayoutEffect(() => void calls.push('useLayoutEffect'), []) + + return ( + + + + ) + } + + await act(async () => root.render()) + + // Should complete tree before layout-effects fire + expect(calls).toStrictEqual(['attach', 'useLayoutEffect']) + expect(lastAttached).toBe(lastMounted) + + await act(async () => root.render()) + + expect(calls).toStrictEqual(['attach', 'useLayoutEffect', 'detach', 'attach']) + expect(lastAttached).toBe(lastMounted) + }) }) From 2941762bbbba4ba289b84080eae585f6c38f66f9 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 07:22:18 -0500 Subject: [PATCH 27/58] chore: fix conflicts --- packages/shared/setupTests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/setupTests.ts b/packages/shared/setupTests.ts index 6c1d564107..c16c0abaf0 100644 --- a/packages/shared/setupTests.ts +++ b/packages/shared/setupTests.ts @@ -57,4 +57,4 @@ function getContext(contextId: string): RenderingContext | null { HTMLCanvasElement.prototype.getContext = getContext // Extend catalogue for render API in tests -extend(THREE) +extend(THREE as any) From 562bdf106d9794c2a99b79b66fa5f2bbbcb000c4 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 07:35:54 -0500 Subject: [PATCH 28/58] chore: fix conflict --- packages/fiber/tests/hooks.test.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/fiber/tests/hooks.test.tsx b/packages/fiber/tests/hooks.test.tsx index c6f4e23d14..236f10280b 100644 --- a/packages/fiber/tests/hooks.test.tsx +++ b/packages/fiber/tests/hooks.test.tsx @@ -1,8 +1,18 @@ import * as React from 'react' import * as THREE from 'three' import * as Stdlib from 'three-stdlib' -import { createRoot, advance, useLoader, act, useThree, useGraph, useFrame, ObjectMap, useInstanceHandle } from '../src' -import { Instance, LocalState } from '../src/core/renderer' +import { + createRoot, + advance, + useLoader, + act, + useThree, + useGraph, + useFrame, + ObjectMap, + useInstanceHandle, + Instance, +} from '../src' const root = createRoot(document.createElement('canvas')) @@ -188,7 +198,7 @@ describe('hooks', () => { it('can handle useInstanceHandle hook', async () => { const ref = React.createRef() - let instance!: React.MutableRefObject + let instance!: React.MutableRefObject const Component = () => { instance = useInstanceHandle(ref) @@ -196,6 +206,6 @@ describe('hooks', () => { } await act(async () => root.render()) - expect(instance.current).toBe((ref.current as unknown as Instance).__r3f) + expect(instance.current).toBe((ref.current as unknown as Instance['object']).__r3f) }) }) From 1ea33c71b1a94077a9cfa46ed680644818e7e805 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 07:38:25 -0500 Subject: [PATCH 29/58] fix(useInstanceHandle): update types to reflect descriptors --- packages/fiber/src/core/hooks.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/fiber/src/core/hooks.tsx b/packages/fiber/src/core/hooks.tsx index 080ac7b9ff..beefc24757 100644 --- a/packages/fiber/src/core/hooks.tsx +++ b/packages/fiber/src/core/hooks.tsx @@ -7,7 +7,7 @@ import { context, RootState, RenderCallback, StageTypes } from './store' import { buildGraph, ObjectMap, is, useMutableCallback, useIsomorphicLayoutEffect } from './utils' import { Stage, Stages, UpdateCallback } from './stages' import { LoadingManager } from 'three' -import { LocalState, Instance } from './renderer' +import { Instance } from './renderer' export interface Loader extends THREE.Loader { load( @@ -24,14 +24,17 @@ export type ConditionalType = Child extends Parent export type BranchingReturn = ConditionalType /** - * Exposes an object's {@link LocalState}. + * Exposes an object's {@link Instance}. * @see https://docs.pmnd.rs/react-three-fiber/api/additional-exports#useInstanceHandle * * **Note**: this is an escape hatch to react-internal fields. Expect this to change significantly between versions. */ -export function useInstanceHandle(ref: React.MutableRefObject): React.MutableRefObject { - const instance = React.useRef(null!) - useIsomorphicLayoutEffect(() => void (instance.current = (ref.current as unknown as Instance).__r3f), [ref]) +export function useInstanceHandle(ref: React.MutableRefObject): React.MutableRefObject { + const instance = React.useRef(null!) + useIsomorphicLayoutEffect( + () => void (instance.current = (ref.current as unknown as Instance['object']).__r3f!), + [ref], + ) return instance } From 66206d19e3a4c3db34de372a69cd2b5207fa8886 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 10:08:59 -0500 Subject: [PATCH 30/58] refactor(types)!: Node => ThreeElement --- docs/tutorials/typescript.mdx | 6 +++--- example/typings/global.d.ts | 4 ++-- packages/fiber/src/core/index.tsx | 6 +++--- packages/fiber/src/three-types.ts | 10 +++++----- packages/fiber/tests/renderer.test.tsx | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/tutorials/typescript.mdx b/docs/tutorials/typescript.mdx index 779289facc..bc00b81452 100644 --- a/docs/tutorials/typescript.mdx +++ b/docs/tutorials/typescript.mdx @@ -91,12 +91,12 @@ You can then declaratively create custom elements with primitives, but TypeScrip ### Extending ThreeElements -To define our element in JSX, we'll use the `Node` interface to extend `ThreeElements`. This interface describes three.js classes that are available in the R3F catalog and can be used as native elements. +To define our element in JSX, we'll use the `ThreeElement` interface to extend `ThreeElements`. This interface describes three.js classes that are available in the R3F catalog and can be used as native elements. ```tsx import { useRef, useEffect } from 'react' import { GridHelper } from 'three' -import { extend, Node } from '@react-three/fiber' +import { extend, ThreeElement } from '@react-three/fiber' // Create our custom element class CustomElement extends GridHelper {} @@ -107,7 +107,7 @@ extend({ CustomElement }) // Add types to ThreeElements elements so primitives pick up on it declare module '@react-three/fiber' { interface ThreeElements { - customElement: Node + customElement: ThreeElement } } diff --git a/example/typings/global.d.ts b/example/typings/global.d.ts index aa5f92d155..f7f31ef597 100644 --- a/example/typings/global.d.ts +++ b/example/typings/global.d.ts @@ -4,7 +4,7 @@ import { DotMaterial } from '../src/demos/Pointcloud' declare module '@react-three/fiber' { interface ThreeElements { - orbitControls: ReactThreeFiber.Node - dotMaterial: ReactThreeFiber.Node + orbitControls: ReactThreeFiber.ThreeElement + dotMaterial: ReactThreeFiber.ThreeElement } } diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index a0f0b95585..7c528596ec 100644 --- a/packages/fiber/src/core/index.tsx +++ b/packages/fiber/src/core/index.tsx @@ -88,9 +88,9 @@ export type RenderProps = { camera?: ( | Camera | Partial< - ReactThreeFiber.Node & - ReactThreeFiber.Node & - ReactThreeFiber.Node + ReactThreeFiber.ThreeElement & + ReactThreeFiber.ThreeElement & + ReactThreeFiber.ThreeElement > ) & { /** Flags the camera as manual, putting projection into your own hands */ diff --git a/packages/fiber/src/three-types.ts b/packages/fiber/src/three-types.ts index 11cc33dfb2..90d43d202d 100644 --- a/packages/fiber/src/three-types.ts +++ b/packages/fiber/src/three-types.ts @@ -35,23 +35,23 @@ interface ReactProps

{ key?: React.Key } -type NodeProps> = Partial< +type ElementProps> = Partial< Overwrite & MathProps

& EventProps

> > -export type Node = Mutable< - Overwrite, Omit>, 'object'>> +export type ThreeElement = Mutable< + Overwrite, Omit>, 'object'>> > type ThreeExports = typeof THREE type ThreeElementsImpl = { [K in keyof ThreeExports as Uncapitalize]: ThreeExports[K] extends ConstructorRepresentation - ? Node + ? ThreeElement : never } export interface ThreeElements extends ThreeElementsImpl { - primitive: Omit, 'args'> & { object: any } + primitive: Omit, 'args'> & { object: any } } declare global { diff --git a/packages/fiber/tests/renderer.test.tsx b/packages/fiber/tests/renderer.test.tsx index 32f85c0b9d..5ed646467a 100644 --- a/packages/fiber/tests/renderer.test.tsx +++ b/packages/fiber/tests/renderer.test.tsx @@ -50,9 +50,9 @@ extend({ HasObject3dMember, HasObject3dMethods }) declare module '@react-three/fiber' { interface ThreeElements { - hasObject3dMember: ReactThreeFiber.Node - hasObject3dMethods: ReactThreeFiber.Node - myColor: ReactThreeFiber.Node + hasObject3dMember: ReactThreeFiber.ThreeElement + hasObject3dMethods: ReactThreeFiber.ThreeElement + myColor: ReactThreeFiber.ThreeElement } } From 21a98c8840fe0812300364d016bf402397d50a7e Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 10:15:37 -0500 Subject: [PATCH 31/58] refactor(types)!: remove ReactThreeFiber namespace --- docs/tutorials/typescript.mdx | 29 -------------------------- example/src/demos/Lines.tsx | 2 +- example/src/demos/Pointcloud.tsx | 2 +- example/typings/global.d.ts | 6 +++--- packages/fiber/src/core/index.tsx | 8 +++---- packages/fiber/src/index.tsx | 2 -- packages/fiber/src/native.tsx | 2 -- packages/fiber/tests/renderer.test.tsx | 17 ++++----------- 8 files changed, 13 insertions(+), 55 deletions(-) diff --git a/docs/tutorials/typescript.mdx b/docs/tutorials/typescript.mdx index bc00b81452..a1646e8cea 100644 --- a/docs/tutorials/typescript.mdx +++ b/docs/tutorials/typescript.mdx @@ -34,35 +34,6 @@ function Box(props) { The exclamation mark is a non-null assertion that will let TS know that `ref.current` is defined when we access it in effects. -## Typing shorthand props - -react-three-fiber accepts short-hand props like scalars, strings, and arrays so you can declaratively set properties without side effects. - -Here are the different variations of props: - -```tsx -import { Euler, Vector3, Color } from 'three' - -rotation: Euler || [x, y, z] -position: Vector3 || [x, y, z] || scalar -color: Color || 'hotpink' || 0xffffff -``` - -Each property has extended types which you can pull from to type these properties. - -```tsx -import { Euler, Vector3, Color } from '@react-three/fiber' -// or -// import { ReactThreeFiber } from '@react-three/fiber' -// ReactThreeFiber.Euler, ReactThreeFiber.Vector3, etc. - -rotation: Euler -position: Vector3 -color: Color -``` - -This is particularly useful if you are typing properties outside of components, such as a store or a hook. - ## Extend usage react-three-fiber can also accept third-party elements and extend them into its internal catalogue. diff --git a/example/src/demos/Lines.tsx b/example/src/demos/Lines.tsx index 600d88f7d0..98c8e07582 100644 --- a/example/src/demos/Lines.tsx +++ b/example/src/demos/Lines.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect, useState, useCallback, useContext, useMemo } from 'react' -import { extend, Canvas, useThree, ReactThreeFiber } from '@react-three/fiber' +import { extend, Canvas, useThree } from '@react-three/fiber' import { OrbitControls } from 'three-stdlib' extend({ OrbitControls }) diff --git a/example/src/demos/Pointcloud.tsx b/example/src/demos/Pointcloud.tsx index ef8927cb6a..57870cf450 100644 --- a/example/src/demos/Pointcloud.tsx +++ b/example/src/demos/Pointcloud.tsx @@ -1,5 +1,5 @@ import React, { useRef, useEffect, useState, useCallback, useContext, useMemo } from 'react' -import { extend, Canvas, useThree, ReactThreeFiber } from '@react-three/fiber' +import { extend, Canvas, useThree } from '@react-three/fiber' import * as THREE from 'three' import { OrbitControls } from 'three-stdlib' extend({ OrbitControls }) diff --git a/example/typings/global.d.ts b/example/typings/global.d.ts index f7f31ef597..ded4d63081 100644 --- a/example/typings/global.d.ts +++ b/example/typings/global.d.ts @@ -1,10 +1,10 @@ -import { ReactThreeFiber } from '@react-three/fiber' +import { ThreeElement } from '@react-three/fiber' import { OrbitControls } from 'three-stdlib' import { DotMaterial } from '../src/demos/Pointcloud' declare module '@react-three/fiber' { interface ThreeElements { - orbitControls: ReactThreeFiber.ThreeElement - dotMaterial: ReactThreeFiber.ThreeElement + orbitControls: ThreeElement + dotMaterial: ThreeElement } } diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index 7c528596ec..64fb8c0814 100644 --- a/packages/fiber/src/core/index.tsx +++ b/packages/fiber/src/core/index.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { ConcurrentRoot } from 'react-reconciler/constants' import create, { StoreApi, UseBoundStore } from 'zustand' -import * as ReactThreeFiber from '../three-types' +import { ThreeElement } from '../three-types' import { Renderer, createStore, @@ -88,9 +88,9 @@ export type RenderProps = { camera?: ( | Camera | Partial< - ReactThreeFiber.ThreeElement & - ReactThreeFiber.ThreeElement & - ReactThreeFiber.ThreeElement + ThreeElement & + ThreeElement & + ThreeElement > ) & { /** Flags the camera as manual, putting projection into your own hands */ diff --git a/packages/fiber/src/index.tsx b/packages/fiber/src/index.tsx index 9f68608ebe..25ee1afad1 100644 --- a/packages/fiber/src/index.tsx +++ b/packages/fiber/src/index.tsx @@ -1,6 +1,4 @@ export * from './three-types' -import * as ReactThreeFiber from './three-types' -export { ReactThreeFiber } export type { AttachFnType, AttachType, diff --git a/packages/fiber/src/native.tsx b/packages/fiber/src/native.tsx index 62d225bd29..851eb278dd 100644 --- a/packages/fiber/src/native.tsx +++ b/packages/fiber/src/native.tsx @@ -1,6 +1,4 @@ export * from './three-types' -import * as ReactThreeFiber from './three-types' -export { ReactThreeFiber } export type { AttachFnType, AttachType, diff --git a/packages/fiber/tests/renderer.test.tsx b/packages/fiber/tests/renderer.test.tsx index 5ed646467a..99a7c54f1e 100644 --- a/packages/fiber/tests/renderer.test.tsx +++ b/packages/fiber/tests/renderer.test.tsx @@ -1,15 +1,6 @@ import * as React from 'react' import * as THREE from 'three' -import { - ReconcilerRoot, - createRoot, - act, - useFrame, - extend, - ReactThreeFiber, - useThree, - createPortal, -} from '../src/index' +import { ReconcilerRoot, createRoot, act, useFrame, extend, ThreeElement, useThree, createPortal } from '../src/index' import { UseBoundStore } from 'zustand' import { privateKeys, RootState } from '../src/core/store' import { Instance } from '../src/core/renderer' @@ -50,9 +41,9 @@ extend({ HasObject3dMember, HasObject3dMethods }) declare module '@react-three/fiber' { interface ThreeElements { - hasObject3dMember: ReactThreeFiber.ThreeElement - hasObject3dMethods: ReactThreeFiber.ThreeElement - myColor: ReactThreeFiber.ThreeElement + hasObject3dMember: ThreeElement + hasObject3dMethods: ThreeElement + myColor: ThreeElement } } From ea8da68997437703420a362440d78170e2ae4f97 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 10:56:08 -0500 Subject: [PATCH 32/58] fix(applyProps): overwrite atomic properties if incompatible --- packages/fiber/src/core/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 4ea1a717c1..3250bf71ac 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -336,7 +336,7 @@ export function applyProps(object: Instance['object'], props: Instan } // If nothing else fits, just set the single value, ignore undefined // https://github.com/pmndrs/react-three-fiber/issues/274 - else if (value !== undefined) { + else if (typeof value === 'number' || value instanceof THREE.Layers) { const isColor = target instanceof THREE.Color // Allow setting array scalars if (!isColor && target.setScalar) target.setScalar(value) @@ -344,6 +344,8 @@ export function applyProps(object: Instance['object'], props: Instan else if (target instanceof THREE.Layers && value instanceof THREE.Layers) target.mask = value.mask // Otherwise just set ... else target.set(value) + } else if (value !== undefined) { + root[key] = value } // Else, just overwrite the value } else { From 9a1519b6de449bb7e851b9d4b656b4460edaae15 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 10:56:44 -0500 Subject: [PATCH 33/58] chore(tests): add resolve and applyProps cases --- packages/fiber/src/core/utils.ts | 2 +- packages/fiber/tests/utils.test.ts | 83 +++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 3250bf71ac..8f9d49d86d 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -183,7 +183,7 @@ export function prepare( return instance } -function resolve(root: any, key: string): { root: any; key: string; target: any } { +export function resolve(root: any, key: string): { root: any; key: string; target: any } { let target = root[key] if (!key.includes('-')) return { root, key, target } diff --git a/packages/fiber/tests/utils.test.ts b/packages/fiber/tests/utils.test.ts index 0e82a123cc..560a418480 100644 --- a/packages/fiber/tests/utils.test.ts +++ b/packages/fiber/tests/utils.test.ts @@ -1,4 +1,5 @@ -import { is } from '../src/core/utils' +import * as THREE from 'three' +import { is, resolve, applyProps } from '../src/core/utils' describe('is', () => { const myFunc = () => null @@ -97,3 +98,83 @@ describe('is', () => { expect(is.equ([1, 2], [1, 2, 3], { strict: false })).toBe(true) }) }) + +describe('resolve', () => { + it('should resolve pierced props', () => { + const object = { foo: { bar: 1 } } + const { root, key, target } = resolve(object, 'foo-bar') + + expect(root).toBe(object['foo']) + expect(key).toBe('bar') + expect(target).toBe(root[key]) + }) + + it('should switch roots for atomic targets', () => { + const bar = new THREE.Vector3() + const object = { foo: { bar } } + const { root, key, target } = resolve(object, 'foo-bar') + + expect(root).toBe(object) + expect(key).toBe('bar') + expect(target).toBe(bar) + }) +}) + +describe('applyProps', () => { + it('should apply props to foreign objects', () => { + const target = new THREE.Object3D() + expect(() => applyProps(target, {})).not.toThrow() + }) + + it('should overwrite non-atomic properties', () => { + const foo = { value: true } + const target = { foo } + applyProps(target, { foo: { value: false } }) + + expect(target.foo).not.toBe(foo) + expect(target.foo.value).toBe(false) + }) + + it('should prefer to copy from external props', async () => { + const color = new THREE.Color() + color.copy = jest.fn() + + const target = { color } + + // Same constructor + applyProps(target, { color: new THREE.Color() }) + expect(target.color).toBeInstanceOf(THREE.Color) + expect(color.copy).toHaveBeenCalledTimes(1) + + // Different constructor + applyProps(target, { color: new THREE.Vector3() }) + expect(target.color).toBeInstanceOf(THREE.Vector3) + expect(color.copy).toHaveBeenCalledTimes(1) + }) + + it('should prefer to set when props are an array', async () => { + const target = new THREE.Object3D() + applyProps(target, { position: [1, 2, 3] }) + + expect(target.position.toArray()).toStrictEqual([1, 2, 3]) + }) + + it('should set with scalar shorthand where applicable', async () => { + const target = new THREE.Object3D() + applyProps(target, { scale: 5 }) + + expect(target.scale.toArray()).toStrictEqual([5, 5, 5]) + + const material = new THREE.MeshBasicMaterial() + applyProps(material, { color: 0x000000 }) + + expect(material.color.getHex()).toBe(0x000000) + }) + + it('should pierce into nested properties', () => { + const target = new THREE.Mesh() + applyProps(target, { 'material-color': 0x000000 }) + + expect(target.material.color.getHex()).toBe(0x000000) + }) +}) From bd6c9d160968c7df148b4a2b3d8e7c1fbcff3601 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 11:14:05 -0500 Subject: [PATCH 34/58] chore: fix conflicts --- packages/fiber/tests/renderer.test.tsx | 15 ++++++++++++--- packages/fiber/tests/setupTests.ts | 23 ----------------------- 2 files changed, 12 insertions(+), 26 deletions(-) delete mode 100644 packages/fiber/tests/setupTests.ts diff --git a/packages/fiber/tests/renderer.test.tsx b/packages/fiber/tests/renderer.test.tsx index 99a7c54f1e..e04c352982 100644 --- a/packages/fiber/tests/renderer.test.tsx +++ b/packages/fiber/tests/renderer.test.tsx @@ -1,9 +1,18 @@ import * as React from 'react' import * as THREE from 'three' -import { ReconcilerRoot, createRoot, act, useFrame, extend, ThreeElement, useThree, createPortal } from '../src/index' +import { + ReconcilerRoot, + createRoot, + act, + useFrame, + extend, + ThreeElement, + useThree, + createPortal, + RootState, +} from '../src/index' import { UseBoundStore } from 'zustand' -import { privateKeys, RootState } from '../src/core/store' -import { Instance } from '../src/core/renderer' +import { privateKeys } from '../src/core/store' import { suspend } from 'suspend-react' type ComponentMesh = THREE.Mesh diff --git a/packages/fiber/tests/setupTests.ts b/packages/fiber/tests/setupTests.ts deleted file mode 100644 index c79798959e..0000000000 --- a/packages/fiber/tests/setupTests.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createWebGLContext } from '@react-three/test-renderer/src/createWebGLContext' -import * as THREE from 'three' -import { extend } from '../src' - -// Polyfills WebGL canvas -function getContext(contextId: '2d', options?: CanvasRenderingContext2DSettings): CanvasRenderingContext2D | null -function getContext( - contextId: 'bitmaprenderer', - options?: ImageBitmapRenderingContextSettings, -): ImageBitmapRenderingContext | null -function getContext(contextId: 'webgl', options?: WebGLContextAttributes): WebGLRenderingContext | null -function getContext(contextId: 'webgl2', options?: WebGLContextAttributes): WebGL2RenderingContext | null -function getContext(contextId: string): RenderingContext | null { - if (contextId === 'webgl' || contextId === 'webgl2') { - return createWebGLContext(this) - } - return null -} - -HTMLCanvasElement.prototype.getContext = getContext - -// Extend catalogue for render API in tests -extend(THREE as any) From 5712ec188d32fd9577643e824225cb7f96063af7 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 12:54:44 -0500 Subject: [PATCH 35/58] chore(tests): add utils coverage --- packages/fiber/src/core/renderer.ts | 2 +- packages/fiber/src/core/utils.ts | 16 +- .../tests/__snapshots__/utils.test.ts.snap | 85 +++++++ packages/fiber/tests/utils.test.ts | 239 +++++++++++++++++- 4 files changed, 332 insertions(+), 10 deletions(-) create mode 100644 packages/fiber/tests/__snapshots__/utils.test.ts.snap diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index b27e409775..ffe88666f4 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -342,7 +342,7 @@ const reconciler = Reconciler< 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) + const changedProps = diffProps(oldProps, newProps, true) if (Object.keys(changedProps).length) return [false, changedProps] // Otherwise do not touch the instance diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 8f9d49d86d..9a8332ff34 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -136,15 +136,15 @@ interface Disposable { export function dispose(obj: T): void { if (obj.type !== 'Scene') obj.dispose?.() for (const p in obj) { - ;(p as Disposable).dispose?.() - delete obj[p] + const prop = obj[p] as Disposable | undefined + if (prop?.type !== 'Scene') prop?.dispose?.() } } -const REACT_INTERNAL_PROPS = ['children', 'key', 'ref'] +export const REACT_INTERNAL_PROPS = ['children', 'key', 'ref'] // Gets only instance props from reconciler fibers -function getInstanceProps(queue: Fiber['pendingProps']): Instance['props'] { +export function getInstanceProps(queue: Fiber['pendingProps']): Instance['props'] { const props: Instance['props'] = {} for (const key in queue) { @@ -233,8 +233,8 @@ export function detach(parent: Instance, child: Instance): void { delete child.previousAttach } -const DEFAULT = '__default' -const RESERVED_PROPS = [ +export const DEFAULT = '__default' +export const RESERVED_PROPS = [ ...REACT_INTERNAL_PROPS, // Instance props 'args', @@ -247,8 +247,8 @@ const RESERVED_PROPS = [ // This function prepares a set of changes to be applied to the instance export function diffProps( - newProps: Instance['props'], oldProps: Instance['props'], + newProps: Instance['props'], remove = false, ): Instance['props'] { const changedProps: Instance['props'] = {} @@ -304,7 +304,7 @@ export function applyProps(object: Instance['object'], props: Instan if (value === DEFAULT + 'remove') { if (target && target.constructor) { // use the prop constructor to find the default it should be - value = new target.constructor(...(instance?.props.args ?? [])) + value = new target.constructor(...(target.__r3f?.props.args ?? instance?.props.args ?? [])) } else if (root.constructor) { // create a blank slate of the instance and copy the particular parameter. // @ts-ignore diff --git a/packages/fiber/tests/__snapshots__/utils.test.ts.snap b/packages/fiber/tests/__snapshots__/utils.test.ts.snap new file mode 100644 index 0000000000..f9204e847e --- /dev/null +++ b/packages/fiber/tests/__snapshots__/utils.test.ts.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`updateCamera updates camera matrices 1`] = ` +Array [ + 2.1445069205095586, + 0, + 0, + 0, + 0, + 2.1445069205095586, + 0, + 0, + 0, + 0, + -1.00010000500025, + -1, + 0, + 0, + -0.200010000500025, + 0, +] +`; + +exports[`updateCamera updates camera matrices 2`] = ` +Array [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, +] +`; + +exports[`updateCamera updates camera matrices 3`] = ` +Array [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + -0.001000050002500125, + 0, + -0, + -0, + -1.00010000500025, + 1, +] +`; + +exports[`updateCamera updates camera matrices 4`] = ` +Array [ + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, +] +`; diff --git a/packages/fiber/tests/utils.test.ts b/packages/fiber/tests/utils.test.ts index 560a418480..2c59464724 100644 --- a/packages/fiber/tests/utils.test.ts +++ b/packages/fiber/tests/utils.test.ts @@ -1,5 +1,20 @@ import * as THREE from 'three' -import { is, resolve, applyProps } from '../src/core/utils' +import { Instance } from '../src' +import { + is, + dispose, + REACT_INTERNAL_PROPS, + getInstanceProps, + prepare, + resolve, + attach, + detach, + RESERVED_PROPS, + diffProps, + DEFAULT, + applyProps, + updateCamera, +} from '../src/core/utils' describe('is', () => { const myFunc = () => null @@ -99,6 +114,70 @@ describe('is', () => { }) }) +describe('dispose', () => { + it('should dispose of objects and their properties', () => { + const mesh = Object.assign(new THREE.Mesh(), { dispose: jest.fn() }) + mesh.material.dispose = jest.fn() + mesh.geometry.dispose = jest.fn() + + dispose(mesh) + expect(mesh.dispose).toBeCalled() + expect(mesh.material.dispose).toBeCalled() + expect(mesh.geometry.dispose).toBeCalled() + }) + + it('should not dispose of a THREE.Scene', () => { + const scene = Object.assign(new THREE.Scene(), { dispose: jest.fn() }) + + dispose(scene) + expect(scene.dispose).not.toBeCalled() + + const disposable = { dispose: jest.fn(), scene } + dispose(disposable) + expect(disposable.dispose).toBeCalled() + expect(disposable.scene.dispose).not.toBeCalled() + }) +}) + +describe('getInstanceProps', () => { + it('should filter internal props without accessing them', () => { + const get = jest.fn() + const set = jest.fn() + + const props = { foo: true } + const filtered = getInstanceProps( + REACT_INTERNAL_PROPS.reduce((acc, key) => ({ ...acc, [key]: { get, set } }), props), + ) + + expect(filtered).toStrictEqual(props) + expect(get).not.toBeCalled() + expect(set).not.toBeCalled() + }) +}) + +describe('prepare', () => { + it('should create an instance descriptor', () => { + const object = new THREE.Object3D() + const root = null! + const instance = prepare(object, root, 'object3D', { name: 'object' }) + + expect(instance.root).toBe(root) + expect(instance.type).toBe('object3D') + expect(instance.props.name).toBe('object') + expect(instance.object).toBe(object) + expect((object as Instance['object']).__r3f).toBe(instance) + }) + + it('should not overwrite descriptors', () => { + const containerDesc = {} + const container = { __r3f: containerDesc } + + const instance = prepare(container, null!, 'container', {}) + expect(container.__r3f).toBe(containerDesc) + expect(instance).toBe(containerDesc) + }) +}) + describe('resolve', () => { it('should resolve pierced props', () => { const object = { foo: { bar: 1 } } @@ -120,12 +199,128 @@ describe('resolve', () => { }) }) +describe('attach / detach', () => { + it('should attach & detach using string values', () => { + const parent = prepare({ prop: null }, null!, '', {}) + const child = prepare({}, null!, '', { attach: 'prop' }) + + attach(parent, child) + expect(parent.object.prop).toBe(child.object) + expect(child.previousAttach).toBe(null) + + detach(parent, child) + expect(parent.object.prop).toBe(null) + expect(child.previousAttach).toBe(undefined) + }) + + it('should attach & detach using attachFns', () => { + const mount = jest.fn() + const unmount = jest.fn() + + const parent = prepare({}, null!, '', {}) + const child = prepare({}, null!, '', { attach: () => (mount(), unmount) }) + + attach(parent, child) + expect(mount).toBeCalledTimes(1) + expect(unmount).toBeCalledTimes(0) + expect(child.previousAttach).toBe(unmount) + + detach(parent, child) + expect(mount).toBeCalledTimes(1) + expect(unmount).toBeCalledTimes(1) + expect(child.previousAttach).toBe(undefined) + }) + + it('should create array when using array-index syntax', () => { + const parent = prepare({ prop: null }, null!, '', {}) + const child = prepare({}, null!, '', { attach: 'prop-0' }) + + attach(parent, child) + expect(parent.object.prop).toStrictEqual([child.object]) + expect(child.previousAttach).toBe(undefined) + + detach(parent, child) + expect((parent.object.prop as unknown as Array).length).toBe(1) + expect((parent.object.prop as unknown as Array)[0]).toBe(undefined) + expect(child.previousAttach).toBe(undefined) + }) +}) + +describe('diffProps', () => { + it('should filter changed props', () => { + const oldProps = { foo: true } + const newProps = { foo: true, bar: false } + + const filtered = diffProps(oldProps, newProps) + expect(filtered).toStrictEqual({ bar: false }) + }) + + it('should pick removed props for HMR', () => { + const oldProps = { foo: true, bar: false } + const newProps = { foo: true } + + const filtered = diffProps(oldProps, newProps, true) + expect(filtered).toStrictEqual({ bar: DEFAULT + 'remove' }) + }) + + it('should filter reserved props without accessing them', () => { + const get = jest.fn() + const set = jest.fn() + + const props = { foo: true } + const filtered = diffProps( + {}, + RESERVED_PROPS.reduce((acc, key) => ({ ...acc, [key]: { get, set } }), props), + ) + + expect(filtered).toStrictEqual(props) + expect(get).not.toBeCalled() + expect(set).not.toBeCalled() + }) +}) + describe('applyProps', () => { it('should apply props to foreign objects', () => { const target = new THREE.Object3D() expect(() => applyProps(target, {})).not.toThrow() }) + it('should filter reserved props without accessing them', () => { + const get = jest.fn() + const set = jest.fn() + + const props = { foo: true } + const target = {} + applyProps( + target, + RESERVED_PROPS.reduce((acc, key) => ({ ...acc, [key]: { get, set } }), props), + ) + + expect(target).toStrictEqual(props) + expect(get).not.toBeCalled() + expect(set).not.toBeCalled() + }) + + it('should reset removed props for HMR', () => { + const target = new THREE.Object3D() + const instance = prepare(target, { getState: () => null! } as any, '', {}) + prepare(target.scale, null!, '', { args: [5, 5, 5] }) + target.position.setScalar(10) + + // Recreate from args + applyProps(target, { scale: DEFAULT + 'remove' }) + expect(target.scale.toArray()).toStrictEqual([5, 5, 5]) + + // Recreate from scratch + applyProps(target, { position: DEFAULT + 'remove' }) + expect(target.position.toArray()).toStrictEqual([0, 0, 0]) + + // Recreate from instance args + instance.props.args = [1, 2, 3] + applyProps(target, { position: DEFAULT + 'remove' }) + expect(target.position.toArray()).toStrictEqual([1, 2, 3]) + }) + it('should overwrite non-atomic properties', () => { const foo = { value: true } const target = { foo } @@ -178,3 +373,45 @@ describe('applyProps', () => { expect(target.material.color.getHex()).toBe(0x000000) }) }) + +describe('updateCamera', () => { + it('updates camera matrices', () => { + const size = { width: 1280, height: 800, left: 0, top: 0 } + + const perspective = new THREE.PerspectiveCamera() + perspective.updateProjectionMatrix = jest.fn() + perspective.updateMatrixWorld = jest.fn() + updateCamera(perspective, size) + expect(perspective.updateProjectionMatrix).toBeCalled() + expect(perspective.updateMatrixWorld).toBeCalled() + expect(perspective.projectionMatrix.toArray()).toMatchSnapshot() + expect(perspective.matrixWorld.toArray()).toMatchSnapshot() + + const orthographic = new THREE.OrthographicCamera() + orthographic.updateProjectionMatrix = jest.fn() + orthographic.updateMatrixWorld = jest.fn() + updateCamera(orthographic, size) + expect(orthographic.updateProjectionMatrix).toBeCalled() + expect(orthographic.updateMatrixWorld).toBeCalled() + expect(orthographic.projectionMatrix.toArray()).toMatchSnapshot() + expect(orthographic.matrixWorld.toArray()).toMatchSnapshot() + }) + + it('respects camera.manual', () => { + const size = { width: 0, height: 0, left: 0, top: 0 } + + const perspective = Object.assign(new THREE.PerspectiveCamera(), { manual: true }) + perspective.updateProjectionMatrix = jest.fn() + perspective.updateMatrixWorld = jest.fn() + updateCamera(perspective, size) + expect(perspective.updateProjectionMatrix).not.toBeCalled() + expect(perspective.updateMatrixWorld).not.toBeCalled() + + const orthographic = Object.assign(new THREE.OrthographicCamera(), { manual: true }) + orthographic.updateProjectionMatrix = jest.fn() + orthographic.updateMatrixWorld = jest.fn() + updateCamera(orthographic, size) + expect(orthographic.updateProjectionMatrix).not.toBeCalled() + expect(orthographic.updateMatrixWorld).not.toBeCalled() + }) +}) From a5c53adc835deb3d8b305529b6b8ec5ec38f1120 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 18:11:52 -0500 Subject: [PATCH 36/58] chore(tests): mock Zustand store in utils --- packages/fiber/tests/utils.test.ts | 35 ++++++++++++++++++------------ 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/fiber/tests/utils.test.ts b/packages/fiber/tests/utils.test.ts index 2c59464724..6b565caf65 100644 --- a/packages/fiber/tests/utils.test.ts +++ b/packages/fiber/tests/utils.test.ts @@ -1,5 +1,6 @@ import * as THREE from 'three' -import { Instance } from '../src' +import { UseBoundStore } from 'zustand' +import { RootState, Instance } from '../src' import { is, dispose, @@ -16,6 +17,14 @@ import { updateCamera, } from '../src/core/utils' +// Mocks a Zustand store +const storeMock: UseBoundStore = Object.assign(() => null!, { + getState: () => null!, + setState() {}, + subscribe: () => () => {}, + destroy() {}, +}) + describe('is', () => { const myFunc = () => null const myObj = { myProp: 'test-prop' } @@ -158,10 +167,9 @@ describe('getInstanceProps', () => { describe('prepare', () => { it('should create an instance descriptor', () => { const object = new THREE.Object3D() - const root = null! - const instance = prepare(object, root, 'object3D', { name: 'object' }) + const instance = prepare(object, storeMock, 'object3D', { name: 'object' }) - expect(instance.root).toBe(root) + expect(instance.root).toBe(storeMock) expect(instance.type).toBe('object3D') expect(instance.props.name).toBe('object') expect(instance.object).toBe(object) @@ -172,7 +180,7 @@ describe('prepare', () => { const containerDesc = {} const container = { __r3f: containerDesc } - const instance = prepare(container, null!, 'container', {}) + const instance = prepare(container, storeMock, 'container', {}) expect(container.__r3f).toBe(containerDesc) expect(instance).toBe(containerDesc) }) @@ -201,8 +209,8 @@ describe('resolve', () => { describe('attach / detach', () => { it('should attach & detach using string values', () => { - const parent = prepare({ prop: null }, null!, '', {}) - const child = prepare({}, null!, '', { attach: 'prop' }) + const parent = prepare({ prop: null }, storeMock, '', {}) + const child = prepare({}, storeMock, '', { attach: 'prop' }) attach(parent, child) expect(parent.object.prop).toBe(child.object) @@ -217,8 +225,8 @@ describe('attach / detach', () => { const mount = jest.fn() const unmount = jest.fn() - const parent = prepare({}, null!, '', {}) - const child = prepare({}, null!, '', { attach: () => (mount(), unmount) }) + const parent = prepare({}, storeMock, '', {}) + const child = prepare({}, storeMock, '', { attach: () => (mount(), unmount) }) attach(parent, child) expect(mount).toBeCalledTimes(1) @@ -232,8 +240,8 @@ describe('attach / detach', () => { }) it('should create array when using array-index syntax', () => { - const parent = prepare({ prop: null }, null!, '', {}) - const child = prepare({}, null!, '', { attach: 'prop-0' }) + const parent = prepare({ prop: null }, storeMock, '', {}) + const child = prepare({}, storeMock, '', { attach: 'prop-0' }) attach(parent, child) expect(parent.object.prop).toStrictEqual([child.object]) @@ -303,8 +311,7 @@ describe('applyProps', () => { it('should reset removed props for HMR', () => { const target = new THREE.Object3D() - const instance = prepare(target, { getState: () => null! } as any, '', {}) - prepare(target.scale, null!, '', { args: [5, 5, 5] }) + prepare(target.scale, storeMock, '', { args: [5, 5, 5] }) target.position.setScalar(10) // Recreate from args @@ -316,7 +323,7 @@ describe('applyProps', () => { expect(target.position.toArray()).toStrictEqual([0, 0, 0]) // Recreate from instance args - instance.props.args = [1, 2, 3] + prepare(target, storeMock, '', { args: [1, 2, 3] }) applyProps(target, { position: DEFAULT + 'remove' }) expect(target.position.toArray()).toStrictEqual([1, 2, 3]) }) From 2e7a951789bbf9aa2a246e1235fa47a8839ab83d Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 18:23:41 -0500 Subject: [PATCH 37/58] chore(utils): cleanup --- packages/fiber/src/core/utils.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 9a8332ff34..fa580a5d2b 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -4,7 +4,7 @@ import type { Fiber } from 'react-reconciler' import type { UseBoundStore } from 'zustand' import type { EventHandlers } from './events' import type { Dpr, RootState, Size } from './store' -import type { Instance } from './renderer' +import type { ConstructorRepresentation, Instance } from './renderer' export type Camera = THREE.OrthographicCamera | THREE.PerspectiveCamera export const isOrthographicCamera = (def: Camera): def is THREE.OrthographicCamera => @@ -56,8 +56,6 @@ export class ErrorBoundary extends React.Component< } } -export type ClassConstructor = { new (): void } - export interface ObjectMap { nodes: { [name: string]: THREE.Object3D } materials: { [name: string]: THREE.Material } @@ -127,7 +125,7 @@ export function buildGraph(object: THREE.Object3D): ObjectMap { return data } -interface Disposable { +export interface Disposable { type?: string dispose?: () => void } @@ -329,8 +327,8 @@ export function applyProps(object: Instance['object'], props: Instan else if ( target.copy && value && - (value as ClassConstructor).constructor && - target.constructor.name === (value as ClassConstructor).constructor.name + (value as ConstructorRepresentation).constructor && + target.constructor.name === (value as ConstructorRepresentation).constructor.name ) { target.copy(value) } From fd3ea46b38a08e3fb276741fb50577d9610d039e Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 21:05:51 -0500 Subject: [PATCH 38/58] chore(tests): harden renderer lifecycle tests --- packages/fiber/src/core/renderer.ts | 2 +- packages/fiber/tests/renderer.test.tsx | 386 ++++++++++++------------- 2 files changed, 179 insertions(+), 209 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index ffe88666f4..f4e59658f3 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -213,7 +213,7 @@ function removeChild( // Remove nested child objects. Primitives should not have objects and children that are // attached to them declaratively ... - if (!isPrimitive && recursive) removeRecursive(child.children, child, recursive, shouldDispose) + if (recursive) removeRecursive(child.children, child, recursive, shouldDispose) // Unlink instance object delete child.object.__r3f diff --git a/packages/fiber/tests/renderer.test.tsx b/packages/fiber/tests/renderer.test.tsx index e04c352982..4c4963e7b8 100644 --- a/packages/fiber/tests/renderer.test.tsx +++ b/packages/fiber/tests/renderer.test.tsx @@ -15,53 +15,17 @@ import { UseBoundStore } from 'zustand' import { privateKeys } from '../src/core/store' import { suspend } from 'suspend-react' -type ComponentMesh = THREE.Mesh - -interface ObjectWithBackground extends THREE.Object3D { - background: THREE.Color -} - -/* This class is used for one of the tests */ -class HasObject3dMember extends THREE.Object3D { - public attachment?: THREE.Object3D = undefined -} - -/* This class is used for one of the tests */ -class HasObject3dMethods extends THREE.Object3D { - attachedObj3d?: THREE.Object3D - detachedObj3d?: THREE.Object3D - - customAttach(obj3d: THREE.Object3D) { - this.attachedObj3d = obj3d - } - - detach(obj3d: THREE.Object3D) { - this.detachedObj3d = obj3d - } -} - -class MyColor extends THREE.Color { - constructor(col: number) { - super(col) - } -} - -extend({ HasObject3dMember, HasObject3dMethods }) +class CustomElement extends THREE.Object3D {} declare module '@react-three/fiber' { interface ThreeElements { - hasObject3dMember: ThreeElement - hasObject3dMethods: ThreeElement - myColor: ThreeElement + customElement: ThreeElement } } -beforeAll(() => { - Object.defineProperty(window, 'devicePixelRatio', { - configurable: true, - value: 2, - }) -}) +extend({ CustomElement }) + +type ComponentMesh = THREE.Mesh describe('renderer', () => { let root: ReconcilerRoot = null! @@ -69,135 +33,60 @@ describe('renderer', () => { beforeEach(() => (root = createRoot(document.createElement('canvas')))) afterEach(async () => act(async () => root.unmount())) - it('renders a simple component', async () => { - const Mesh = () => ( - - - - - ) - const store = await act(async () => root.render()) + it('should render empty JSX', async () => { + const store = await act(async () => root.render(null)) const { scene } = store.getState() - expect(scene.children[0].type).toEqual('Mesh') - expect((scene.children[0] as ComponentMesh).geometry.type).toEqual('BoxGeometry') - expect((scene.children[0] as ComponentMesh).material.type).toEqual('MeshBasicMaterial') - expect((scene.children[0] as THREE.Mesh).material.type).toEqual( - 'MeshBasicMaterial', - ) + expect(scene.children.length).toBe(0) }) - it('renders an empty scene', async () => { - const Empty = () => null - - const store = await act(async () => root.render()) + it('should render native elements', async () => { + const store = await act(async () => root.render()) const { scene } = store.getState() - expect(scene.type).toEqual('Scene') - expect(scene.children).toEqual([]) + expect(scene.children.length).toBe(1) + expect(scene.children[0]).toBeInstanceOf(THREE.Group) }) - it('can render a composite component', async () => { - const Child = () => ( - - - - - ) - - class Parent extends React.Component { - render() { - return ( - - - - - ) - } - } - - const store = await act(async () => root.render()) + it('should render extended elements', async () => { + const store = await act(async () => root.render()) const { scene } = store.getState() - const parent = scene.children[0] as ObjectWithBackground - expect(parent).toBeInstanceOf(THREE.Group) - expect(parent.background.getStyle()).toEqual('rgb(0,0,0)') - - const child = parent.children[0] as ComponentMesh - expect(child).toBeInstanceOf(THREE.Mesh) - expect(child.geometry).toBeInstanceOf(THREE.BoxGeometry) - expect(child.material).toBeInstanceOf(THREE.MeshBasicMaterial) + expect(scene.children.length).toBe(1) + expect(scene.children[0]).toBeInstanceOf(CustomElement) }) - it('renders some basics with an update', async () => { - let renders = 0 - - class Component extends React.PureComponent { - state = { pos: 3 } - - componentDidMount() { - this.setState({ pos: 7 }) - } - - render() { - renders++ - return ( - - - - - ) - } - } - - const Child = () => { - renders++ - return - } - - const Null = () => { - renders++ - return null - } + it('should render primitives', async () => { + const object = new THREE.Object3D() - const store = await act(async () => root.render()) + const store = await act(async () => root.render()) const { scene } = store.getState() - expect(scene.children[0].position.x).toEqual(7) - expect(renders).toBe(6) + expect(scene.children.length).toBe(1) + expect(scene.children[0]).toBe(object) }) - it('updates types & names', async () => { - const store = await act(async () => - root.render( - - - - - , - ), - ) - const { scene } = store.getState() - - const basic = scene.children[0] as ComponentMesh - expect(basic.material).toBeInstanceOf(THREE.MeshBasicMaterial) - expect(basic.material.name).toBe('basicMat') - expect(basic.material.color.toArray()).toStrictEqual([0, 0, 0]) + it('should go through lifecycle', async () => { + const lifecycle: string[] = [] - await act(async () => - root.render( - - - - - , - ), - ) + function Test() { + React.useInsertionEffect(() => void lifecycle.push('useInsertionEffect'), []) + React.useImperativeHandle(React.useRef(), () => void lifecycle.push('refCallback')) + React.useLayoutEffect(() => void lifecycle.push('useLayoutEffect'), []) + React.useEffect(() => void lifecycle.push('useEffect'), []) + lifecycle.push('render') + return void lifecycle.push('ref')} /> + } + await act(async () => root.render()) - const standard = scene.children[0] as ComponentMesh - expect(standard.material).toBeInstanceOf(THREE.MeshStandardMaterial) - expect(standard.material.name).toBe('standardMat') - expect(standard.material.color.toArray()).toStrictEqual([255, 255, 255]) + expect(lifecycle).toStrictEqual([ + 'render', + 'useInsertionEffect', + 'ref', + 'refCallback', + 'useLayoutEffect', + 'useEffect', + ]) }) it('should forward ref three object', async () => { @@ -227,63 +116,155 @@ describe('renderer', () => { expect(mutableRefSpecific.current).toBeInstanceOf(THREE.Mesh) }) - it('attaches children that use attach', async () => { - const store = await act(async () => - root.render( - - - , - ), + it('should handle children', async () => { + const Test = () => ( + + + ) + const store = await act(async () => root.render()) const { scene } = store.getState() - const object = scene.children[0] as HasObject3dMember - expect(object.attachment).toBeInstanceOf(THREE.Mesh) - expect(object.children.length).toBe(0) + expect(scene.children.length).toBe(1) + expect(scene.children[0]).toBeInstanceOf(THREE.Group) + expect(scene.children[0].children.length).toBe(1) + expect(scene.children[0].children[0]).toBeInstanceOf(THREE.Mesh) }) - describe('attaches children that use attachFns', () => { - it('attachFns with cleanup', async () => { - const store = await act(async () => - root.render( - - (parent.customAttach(self), () => parent.detach(self))} /> - , - ), + it('should handle attach', async () => { + const lifecycle: string[] = [] + + const Test = () => { + return ( + + + + + void lifecycle.push('mount')} + attach={() => (lifecycle.push('attach'), () => lifecycle.push('detach'))} + /> + ) - const { scene } = store.getState() + } + const store = await act(async () => root.render()) + const { scene } = store.getState() - // Attach - const object = scene.children[0] as HasObject3dMethods - expect(object.attachedObj3d).toBeInstanceOf(THREE.Mesh) - expect(object.children.length).toBe(0) + expect(scene.children.length).toBe(1) + expect(scene.children[0]).toBeInstanceOf(THREE.Mesh) + // Handles geometry & material attach + expect((scene.children[0] as ComponentMesh).geometry).toBeInstanceOf(THREE.BoxGeometry) + expect((scene.children[0] as ComponentMesh).material).toBeInstanceOf(THREE.MeshStandardMaterial) + // Handles nested attach + expect(scene.children[0].userData.group).toBeInstanceOf(THREE.Group) + // attach bypasses scene-graph + expect(scene.children[0].children.length).toBe(0) + // attaches before presenting + expect(lifecycle).toStrictEqual(['attach', 'mount']) + }) - // Detach - expect(object.detachedObj3d).toBeUndefined() - await act(async () => root.render()) - expect(object.detachedObj3d).toBeInstanceOf(THREE.Mesh) - }) + it('should handle unmount', async () => { + const dispose = jest.fn() + const childDispose = jest.fn() + const attachDispose = jest.fn() + const flagDispose = jest.fn() + + const attach = jest.fn() + const detach = jest.fn() + + const object = Object.assign(new THREE.Object3D(), { dispose: jest.fn() }) + + const Test = (props: JSX.IntrinsicElements['mesh']) => ( + { + if (!self) return + self.dispose = dispose + }}> + { + if (!self) return + self.dispose = childDispose + }} + /> + { + if (!self) return + self.dispose = attachDispose + }} + attach={() => (attach(), detach)} + /> + { + if (!self) return + self.dispose = flagDispose + }} + /> + + + ) + + const store = await act(async () => root.render()) + await act(async () => root.render(null)) + + const { scene } = store.getState() + + // TODO: Scheduler isn't being flushed and Jest's mocks are clashing here. + // We need a way to check usage of dispose after unmount that works with Jest + dispose() + childDispose() + attachDispose() + detach() + + // Cleans up scene-graph + expect(scene.children.length).toBe(0) + // Calls dispose on top-level instance + expect(dispose).toBeCalled() + // Also disposes of children + expect(childDispose).toBeCalled() + // Disposes of attached children + expect(attachDispose).toBeCalled() + // Properly detaches attached children + expect(attach).toBeCalledTimes(1) + expect(detach).toBeCalledTimes(1) + // Respects dispose={null} + expect(flagDispose).not.toBeCalled() + // Does not dispose of primitives + expect(object.dispose).not.toBeCalled() }) - it('does the full lifecycle', async () => { - const log: string[] = [] - class Log extends React.Component<{ name: string }> { - render() { - log.push('render ' + this.props.name) - return - } - componentDidMount() { - log.push('mount ' + this.props.name) - } - componentWillUnmount() { - log.push('unmount ' + this.props.name) - } - } + it('updates types & names', async () => { + const store = await act(async () => + root.render( + + + + + , + ), + ) + const { scene } = store.getState() - await act(async () => root.render()) - await act(async () => root.unmount()) + const basic = scene.children[0] as ComponentMesh + expect(basic.material).toBeInstanceOf(THREE.MeshBasicMaterial) + expect(basic.material.name).toBe('basicMat') + expect(basic.material.color.toArray()).toStrictEqual([0, 0, 0]) + + await act(async () => + root.render( + + + + + , + ), + ) - expect(log).toEqual(['render Foo', 'mount Foo', 'unmount Foo']) + const standard = scene.children[0] as ComponentMesh + expect(standard.material).toBeInstanceOf(THREE.MeshStandardMaterial) + expect(standard.material.name).toBe('standardMat') + expect(standard.material.color.toArray()).toStrictEqual([255, 255, 255]) }) it('will mount/unmount event handlers correctly', async () => { @@ -414,7 +395,7 @@ describe('renderer', () => { state = root.configure({ dpr: [1, 2], performance: { min: 0.2 } }).render() }) - expect(state.getState().viewport.initialDpr).toEqual(2) + expect(state.getState().viewport.initialDpr).toEqual(window.devicePixelRatio) expect(state.getState().performance.min).toEqual(0.2) expect(state.getState().performance.current).toEqual(1) @@ -493,17 +474,6 @@ describe('renderer', () => { expect(respected).toEqual(true) }) - it('will render components that are extended', async () => { - extend({ MyColor }) - - const store = await act(async () => root.render()) - const { scene } = store.getState() - - const { myColor } = scene as THREE.Scene & { myColor: MyColor } - expect(myColor).toBeInstanceOf(MyColor) - expect(myColor.toArray()).toStrictEqual([0, 0, 1]) - }) - it('should set renderer props via gl prop', async () => { const store = await act(async () => root.configure({ gl: { physicallyCorrectLights: true } }).render()) const { gl } = store.getState() From f357028dd12c5f2af8c909af629eb5252b0cb55e Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 22:40:22 -0500 Subject: [PATCH 39/58] chore: mock scheduler/unstable_scheduleCallback --- packages/shared/setupTests.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/shared/setupTests.ts b/packages/shared/setupTests.ts index c16c0abaf0..f42e611323 100644 --- a/packages/shared/setupTests.ts +++ b/packages/shared/setupTests.ts @@ -11,7 +11,10 @@ declare global { global.IS_REACT_ACT_ENVIRONMENT = true // Mock scheduler to test React features -jest.mock('scheduler', () => require('scheduler/unstable_mock')) +jest.mock('scheduler', () => ({ + ...jest.requireActual('scheduler/unstable_mock'), + unstable_scheduleCallback: (_: any, callback: () => void) => callback(), +})) // PointerEvent is not in JSDOM // https://github.com/jsdom/jsdom/pull/2666#issuecomment-691216178 From 9289a305626504d807a8db92d3042a6e01ebe632 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 22:40:49 -0500 Subject: [PATCH 40/58] fix(renderer): don't mutate while removing recursively --- packages/fiber/src/core/renderer.ts | 30 ++++++++++++----------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index f4e59658f3..ea71b855a7 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -167,29 +167,20 @@ function insertBefore( invalidateInstance(child) } -function removeRecursive( - children: HostConfig['instance'][], - parent: HostConfig['instance'], - recursive: boolean = false, - dispose: boolean = false, -) { - for (const child of children) { - removeChild(parent, child, recursive, dispose) - } -} - function removeChild( parent: HostConfig['instance'], child: HostConfig['instance'] | HostConfig['textInstance'], - recursive = true, dispose?: boolean, + recursive?: boolean, ) { if (!child) return // Unlink instances child.parent = null - const childIndex = parent.children.indexOf(child) - if (childIndex !== -1) parent.children.splice(childIndex, 1) + if (recursive === undefined) { + const childIndex = parent.children.indexOf(child) + if (childIndex !== -1) parent.children.splice(childIndex, 1) + } // Eagerly tear down tree if (child.props.attach) { @@ -208,18 +199,21 @@ function removeChild( // // 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) + const shouldDispose = child.props.dispose !== null && dispose !== false // Remove nested child objects. Primitives should not have objects and children that are // attached to them declaratively ... - if (recursive) removeRecursive(child.children, child, recursive, shouldDispose) + if (recursive !== false) { + for (const childInstance of child.children) { + removeChild(child, childInstance, shouldDispose, true) + } + } // Unlink instance object delete child.object.__r3f // Dispose object whenever the reconciler feels like it - if (child.object.type !== 'Scene' && shouldDispose) { + if (child.type !== 'primitive' && child.object.type !== 'Scene' && shouldDispose) { const dispose = child.object.dispose if (typeof dispose === 'function') { scheduleCallback(idlePriority, () => { From e58afbb07bf2de2e17bd806ed4b8480f6f2f0c9d Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 22:41:02 -0500 Subject: [PATCH 41/58] chore(tests): remove scheduler workaround --- packages/fiber/tests/renderer.test.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/fiber/tests/renderer.test.tsx b/packages/fiber/tests/renderer.test.tsx index 4c4963e7b8..19b22dbbb2 100644 --- a/packages/fiber/tests/renderer.test.tsx +++ b/packages/fiber/tests/renderer.test.tsx @@ -210,13 +210,6 @@ describe('renderer', () => { const { scene } = store.getState() - // TODO: Scheduler isn't being flushed and Jest's mocks are clashing here. - // We need a way to check usage of dispose after unmount that works with Jest - dispose() - childDispose() - attachDispose() - detach() - // Cleans up scene-graph expect(scene.children.length).toBe(0) // Calls dispose on top-level instance From c6aa93958ecfa90b9c0dd4a1b2cdde9b0d99848a Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 23:12:19 -0500 Subject: [PATCH 42/58] fix(core): unlink on no preference --- packages/fiber/src/core/renderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index ea71b855a7..c64dda9acd 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -177,7 +177,7 @@ function removeChild( // Unlink instances child.parent = null - if (recursive === undefined) { + if (!recursive) { const childIndex = parent.children.indexOf(child) if (childIndex !== -1) parent.children.splice(childIndex, 1) } From 690f11220067dc053001d68852a5e5c7d4d875d2 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Fri, 9 Sep 2022 23:15:57 -0500 Subject: [PATCH 43/58] chore(tests): harden primitive children dispose case --- packages/fiber/tests/renderer.test.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/fiber/tests/renderer.test.tsx b/packages/fiber/tests/renderer.test.tsx index 19b22dbbb2..e51d81be38 100644 --- a/packages/fiber/tests/renderer.test.tsx +++ b/packages/fiber/tests/renderer.test.tsx @@ -173,6 +173,10 @@ describe('renderer', () => { const detach = jest.fn() const object = Object.assign(new THREE.Object3D(), { dispose: jest.fn() }) + const objectExternal = Object.assign(new THREE.Object3D(), { dispose: jest.fn() }) + object.add(objectExternal) + + const disposeDeclarativePrimitive = jest.fn() const Test = (props: JSX.IntrinsicElements['mesh']) => ( { self.dispose = flagDispose }} /> - + + { + if (!self) return + self.dispose = disposeDeclarativePrimitive + }} + /> + ) @@ -225,6 +236,9 @@ describe('renderer', () => { expect(flagDispose).not.toBeCalled() // Does not dispose of primitives expect(object.dispose).not.toBeCalled() + // Only disposes of declarative primitive children + expect(objectExternal.dispose).not.toBeCalled() + expect(disposeDeclarativePrimitive).toBeCalled() }) it('updates types & names', async () => { From 7d1db95a59323a4534feaa4cdb5d469e00f13353 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 05:05:54 -0500 Subject: [PATCH 44/58] fix(applyProps): don't try to construct literals on HMR --- packages/fiber/src/core/utils.ts | 4 ++-- packages/fiber/tests/utils.test.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index fa580a5d2b..9e3ecf2e27 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -300,14 +300,14 @@ export function applyProps(object: Instance['object'], props: Instan // with their respective constructor/set arguments // For removed props, try to set default values, if possible if (value === DEFAULT + 'remove') { - if (target && target.constructor) { + if (target && typeof target === 'object' && target.constructor) { // use the prop constructor to find the default it should be value = new target.constructor(...(target.__r3f?.props.args ?? instance?.props.args ?? [])) } else if (root.constructor) { // create a blank slate of the instance and copy the particular parameter. // @ts-ignore const defaultClassCall = new root.constructor(...(root.__r3f?.props.args ?? [])) - value = defaultClassCall[target] + value = defaultClassCall[key] // destroy the instance if (defaultClassCall.dispose) defaultClassCall.dispose() } else { diff --git a/packages/fiber/tests/utils.test.ts b/packages/fiber/tests/utils.test.ts index 6b565caf65..4319454151 100644 --- a/packages/fiber/tests/utils.test.ts +++ b/packages/fiber/tests/utils.test.ts @@ -310,7 +310,14 @@ describe('applyProps', () => { }) it('should reset removed props for HMR', () => { - const target = new THREE.Object3D() + class Target extends THREE.Object3D { + constructor(x = 0, y = 0, z = 0) { + super() + this.position.set(x, y, z) + } + } + + const target = new Target() prepare(target.scale, storeMock, '', { args: [5, 5, 5] }) target.position.setScalar(10) From 972106c8e5c43beff780e70343589dca3faad926 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 05:08:45 -0500 Subject: [PATCH 45/58] refactor(applyProps): pick from root obj on HMR --- packages/fiber/src/core/utils.ts | 5 +---- packages/fiber/tests/utils.test.ts | 5 ----- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 9e3ecf2e27..6115c1197a 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -300,10 +300,7 @@ export function applyProps(object: Instance['object'], props: Instan // with their respective constructor/set arguments // For removed props, try to set default values, if possible if (value === DEFAULT + 'remove') { - if (target && typeof target === 'object' && target.constructor) { - // use the prop constructor to find the default it should be - value = new target.constructor(...(target.__r3f?.props.args ?? instance?.props.args ?? [])) - } else if (root.constructor) { + if (root.constructor) { // create a blank slate of the instance and copy the particular parameter. // @ts-ignore const defaultClassCall = new root.constructor(...(root.__r3f?.props.args ?? [])) diff --git a/packages/fiber/tests/utils.test.ts b/packages/fiber/tests/utils.test.ts index 4319454151..10c472098e 100644 --- a/packages/fiber/tests/utils.test.ts +++ b/packages/fiber/tests/utils.test.ts @@ -318,13 +318,8 @@ describe('applyProps', () => { } const target = new Target() - prepare(target.scale, storeMock, '', { args: [5, 5, 5] }) target.position.setScalar(10) - // Recreate from args - applyProps(target, { scale: DEFAULT + 'remove' }) - expect(target.scale.toArray()).toStrictEqual([5, 5, 5]) - // Recreate from scratch applyProps(target, { position: DEFAULT + 'remove' }) expect(target.position.toArray()).toStrictEqual([0, 0, 0]) From 5cb947be72d7e293fca48c435258e777e309864d Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 05:44:25 -0500 Subject: [PATCH 46/58] fix(types): primitive accepts truthy non-literal types --- packages/fiber/src/three-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiber/src/three-types.ts b/packages/fiber/src/three-types.ts index 90d43d202d..e5cfa5a9ae 100644 --- a/packages/fiber/src/three-types.ts +++ b/packages/fiber/src/three-types.ts @@ -51,7 +51,7 @@ type ThreeElementsImpl = { } export interface ThreeElements extends ThreeElementsImpl { - primitive: Omit, 'args'> & { object: any } + primitive: Omit, 'args'> & { object: object } } declare global { From eeaebd36f5e1e0bf5aebfade601630b7071569db Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 05:44:39 -0500 Subject: [PATCH 47/58] fix(renderer): revalidate args on prop change --- packages/fiber/src/core/renderer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index c64dda9acd..b4f04e0285 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -331,6 +331,10 @@ const reconciler = Reconciler< // Reconstruct primitives if object prop changes if (instance.type === 'primitive' && oldProps.object !== newProps.object) return [true] + // Throw if an object or literal was passed for args + if (newProps.args !== undefined && !Array.isArray(newProps.args)) + throw new Error('R3F: The args prop must be an array!') + // Reconstruct instance if args change if (newProps.args?.length !== oldProps.args?.length) return [true] if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) return [true] From 556da627bfbc4245a58d3e48126fe07ffaaf5bbe Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 07:51:49 -0500 Subject: [PATCH 48/58] fix(core): don't mutate on swap, de-dup events --- packages/fiber/src/core/renderer.ts | 18 ++++++------------ .../__snapshots__/RTTR.core.test.tsx.snap | 11 +++++++++++ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index b4f04e0285..f6ea688947 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -177,7 +177,7 @@ function removeChild( // Unlink instances child.parent = null - if (!recursive) { + if (recursive === undefined) { const childIndex = parent.children.indexOf(child) if (childIndex !== -1) parent.children.splice(childIndex, 1) } @@ -239,13 +239,6 @@ function switchInstance( // Create a new instance const newInstance = createInstance(type, props, oldInstance.root) - // Link up new instance - const parent = oldInstance.parent - if (parent) { - removeChild(parent, oldInstance, false) - appendChild(parent, newInstance) - } - // Move children to new instance for (const child of oldInstance.children) { removeChild(oldInstance, child, false, false) @@ -253,10 +246,11 @@ function switchInstance( } oldInstance.children = [] - // Re-bind event handlers - if (newInstance.object.raycast !== null && newInstance.object instanceof THREE.Object3D && newInstance.eventCount) { - const rootState = newInstance.root.getState() - rootState.internal.interaction.push(newInstance.object) + // Link up new instance + const parent = oldInstance.parent + if (parent) { + removeChild(parent, oldInstance, true, false) + appendChild(parent, newInstance) } // This evil hack switches the react-internal fiber node 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 6d7b8f81dc..3df1a6374d 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 @@ -386,6 +386,17 @@ Array [ }, Object { "children": Array [ + Object { + "children": Array [], + "props": Object { + "args": Array [ + 6, + 6, + ], + "attach": "geometry", + }, + "type": "boxGeometry", + }, Object { "children": Array [], "props": Object { From d09a5d68f0d470f0b0b57b0855303073b153e7d0 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 08:14:44 -0500 Subject: [PATCH 49/58] fix(applyProps): handle event handler HMR --- packages/fiber/src/core/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 6115c1197a..4c4a8292d8 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -287,7 +287,7 @@ export function applyProps(object: Instance['object'], props: Instan // 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 + if (typeof value === 'function') instance.handlers[prop as keyof EventHandlers] = value as any else delete instance.handlers[prop as keyof EventHandlers] instance.eventCount = Object.keys(instance.handlers).length } From de5c80fc14851ce664552f21e480effd044156c4 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 08:26:27 -0500 Subject: [PATCH 50/58] chore(tests): prop diffing & updates, harden reconstruct, isolate reconciler cases --- packages/fiber/tests/index.test.tsx | 220 ++++++++++ packages/fiber/tests/renderer.test.tsx | 533 ++++++++----------------- 2 files changed, 391 insertions(+), 362 deletions(-) create mode 100644 packages/fiber/tests/index.test.tsx diff --git a/packages/fiber/tests/index.test.tsx b/packages/fiber/tests/index.test.tsx new file mode 100644 index 0000000000..016702a758 --- /dev/null +++ b/packages/fiber/tests/index.test.tsx @@ -0,0 +1,220 @@ +import * as React from 'react' +import * as THREE from 'three' +import { ReconcilerRoot, createRoot, act, useFrame, useThree, createPortal, RootState } from '../src/index' +import { UseBoundStore } from 'zustand' +import { privateKeys } from '../src/core/store' + +let root: ReconcilerRoot = null! + +beforeEach(() => (root = createRoot(document.createElement('canvas')))) +afterEach(async () => act(async () => root.unmount())) + +describe('createRoot', () => { + it('should return a Zustand store', async () => { + const store = await act(async () => root.render(null)) + expect(() => store.getState()).not.toThrow() + }) + + it('will make an Orthographic Camera & set the position', async () => { + const store = await act(async () => + root.configure({ orthographic: true, camera: { position: [0, 0, 5] } }).render(), + ) + const { camera } = store.getState() + + expect(camera).toBeInstanceOf(THREE.OrthographicCamera) + expect(camera.position.z).toEqual(5) + }) + + it('should handle an performance changing functions', async () => { + let state: UseBoundStore = null! + await act(async () => { + state = root.configure({ dpr: [1, 2], performance: { min: 0.2 } }).render() + }) + + expect(state.getState().viewport.initialDpr).toEqual(window.devicePixelRatio) + expect(state.getState().performance.min).toEqual(0.2) + expect(state.getState().performance.current).toEqual(1) + + await act(async () => { + state.getState().setDpr(0.1) + }) + + expect(state.getState().viewport.dpr).toEqual(0.1) + + jest.useFakeTimers() + + await act(async () => { + state.getState().performance.regress() + jest.advanceTimersByTime(100) + }) + + expect(state.getState().performance.current).toEqual(0.2) + + await act(async () => { + jest.advanceTimersByTime(200) + }) + + expect(state.getState().performance.current).toEqual(1) + + jest.useRealTimers() + }) + + it('should set PCFSoftShadowMap as the default shadow map', async () => { + const store = await act(async () => root.configure({ shadows: true }).render()) + const { gl } = store.getState() + + expect(gl.shadowMap.type).toBe(THREE.PCFSoftShadowMap) + }) + + it('should set tonemapping to ACESFilmicToneMapping and outputEncoding to sRGBEncoding if linear is false', async () => { + const store = await act(async () => root.configure({ linear: false }).render()) + const { gl } = store.getState() + + expect(gl.toneMapping).toBe(THREE.ACESFilmicToneMapping) + expect(gl.outputEncoding).toBe(THREE.sRGBEncoding) + }) + + it('should toggle render mode in xr', async () => { + let state: RootState = null! + + await act(async () => { + state = root.render().getState() + state.gl.xr.isPresenting = true + state.gl.xr.dispatchEvent({ type: 'sessionstart' }) + }) + + expect(state.gl.xr.enabled).toEqual(true) + + await act(async () => { + state.gl.xr.isPresenting = false + state.gl.xr.dispatchEvent({ type: 'sessionend' }) + }) + + expect(state.gl.xr.enabled).toEqual(false) + }) + + it('should respect frameloop="never" in xr', async () => { + let respected = true + + const Test = () => useFrame(() => (respected = false)) + + await act(async () => { + const state = root + .configure({ frameloop: 'never' }) + .render() + .getState() + state.gl.xr.isPresenting = true + state.gl.xr.dispatchEvent({ type: 'sessionstart' }) + }) + + expect(respected).toEqual(true) + }) + + it('should set renderer props via gl prop', async () => { + const store = await act(async () => root.configure({ gl: { physicallyCorrectLights: true } }).render()) + const { gl } = store.getState() + + expect(gl.physicallyCorrectLights).toBe(true) + }) + + it('should set a renderer via gl callback', async () => { + class Renderer extends THREE.WebGLRenderer {} + + const store = await act(async () => root.configure({ gl: (canvas) => new Renderer({ canvas }) }).render()) + const { gl } = store.getState() + + expect(gl instanceof Renderer).toBe(true) + }) + + it('should respect color management preferences via gl', async () => { + const store = await act(async () => + root + .configure({ gl: { outputEncoding: THREE.LinearEncoding, toneMapping: THREE.NoToneMapping } }) + .render(), + ) + const { gl } = store.getState() + + expect(gl.outputEncoding).toBe(THREE.LinearEncoding) + expect(gl.toneMapping).toBe(THREE.NoToneMapping) + + await act(async () => root.configure({ flat: true, linear: true }).render()) + expect(gl.outputEncoding).toBe(THREE.LinearEncoding) + expect(gl.toneMapping).toBe(THREE.NoToneMapping) + }) + + it('should respect legacy prop', async () => { + await act(async () => root.configure({ legacy: true }).render()) + expect((THREE as any).ColorManagement.legacyMode).toBe(true) + + await act(async () => root.configure({ legacy: false }).render()) + expect((THREE as any).ColorManagement.legacyMode).toBe(false) + }) +}) + +describe('createPortal', () => { + it('should create a state enclave', async () => { + const scene = new THREE.Scene() + + let state: RootState = null! + let portalState: RootState = null! + + const Normal = () => { + const three = useThree() + state = three + + return + } + + const Portal = () => { + const three = useThree() + portalState = three + + return + } + + await act(async () => { + root.render( + <> + + {createPortal(, scene, { scene })} + , + ) + }) + + // Renders into portal target + expect(scene.children.length).not.toBe(0) + + // Creates an isolated state enclave + expect(state.scene).not.toBe(scene) + expect(portalState.scene).toBe(scene) + + // Preserves internal keys + const overwrittenKeys = ['get', 'set', 'events', 'size', 'viewport'] + const respectedKeys = privateKeys.filter((key) => overwrittenKeys.includes(key) || state[key] === portalState[key]) + expect(respectedKeys).toStrictEqual(privateKeys) + }) + + it('should handle unmounted containers', async () => { + let groupHandle!: THREE.Group | null + function Test(props: any) { + const [group, setGroup] = React.useState(null) + groupHandle = group + + return ( + + {group && createPortal(, group)} + + ) + } + + await act(async () => root.render()) + + expect(groupHandle).toBeDefined() + const prevUUID = groupHandle!.uuid + + await act(async () => root.render()) + + expect(groupHandle).toBeDefined() + expect(prevUUID).not.toBe(groupHandle!.uuid) + }) +}) diff --git a/packages/fiber/tests/renderer.test.tsx b/packages/fiber/tests/renderer.test.tsx index e51d81be38..e1465ae2be 100644 --- a/packages/fiber/tests/renderer.test.tsx +++ b/packages/fiber/tests/renderer.test.tsx @@ -1,18 +1,6 @@ import * as React from 'react' import * as THREE from 'three' -import { - ReconcilerRoot, - createRoot, - act, - useFrame, - extend, - ThreeElement, - useThree, - createPortal, - RootState, -} from '../src/index' -import { UseBoundStore } from 'zustand' -import { privateKeys } from '../src/core/store' +import { ReconcilerRoot, createRoot, act, extend, ThreeElement } from '../src/index' import { suspend } from 'suspend-react' class CustomElement extends THREE.Object3D {} @@ -27,6 +15,22 @@ extend({ CustomElement }) type ComponentMesh = THREE.Mesh +const expectToThrow = async (callback: () => any) => { + const error = console.error + console.error = jest.fn() + + let thrown = false + try { + await callback() + } catch (_) { + thrown = true + } + + expect(thrown).toBe(true) + expect(console.error).toBeCalled() + console.error = error +} + describe('renderer', () => { let root: ReconcilerRoot = null! @@ -163,6 +167,154 @@ describe('renderer', () => { expect(lifecycle).toStrictEqual(['attach', 'mount']) }) + it('should update props reactively', async () => { + const store = await act(async () => root.render()) + const { scene } = store.getState() + const group = scene.children[0] as THREE.Group + + // Initial + expect(group.name).toBe(new THREE.Group().name) + + // Set + await act(async () => root.render()) + expect(group.name).toBe('one') + + // Update + await act(async () => root.render()) + expect(group.name).toBe('two') + + // Unset + await act(async () => root.render()) + expect(group.name).toBe(new THREE.Group().name) + }) + + it('should handle event props reactively', async () => { + const store = await act(async () => root.render()) + const { scene, internal } = store.getState() + const mesh = scene.children[0] as ComponentMesh + mesh.name = 'current' + + // Initial + expect(internal.interaction.length).toBe(0) + + // Set + await act(async () => root.render( void 0} />)) + expect(internal.interaction.length).toBe(1) + expect(internal.interaction).toStrictEqual([mesh]) + + // Update + await act(async () => root.render( void 0} />)) + expect(internal.interaction.length).toBe(1) + expect(internal.interaction).toStrictEqual([mesh]) + + // Unset + await act(async () => root.render()) + expect(internal.interaction.length).toBe(0) + }) + + it('should handle the args prop reactively', async () => { + const ref = React.createRef() + const child = React.createRef() + const attachedChild = React.createRef() + + const Test = (props: JSX.IntrinsicElements['mesh']) => ( + + + + + ) + + // Initial + await act(async () => root.render()) + expect(ref.current!.geometry).toBeInstanceOf(THREE.BufferGeometry) + expect(ref.current!.geometry).not.toBeInstanceOf(THREE.BoxGeometry) + expect(ref.current!.material).toBeInstanceOf(THREE.Material) + expect(ref.current!.material).not.toBeInstanceOf(THREE.MeshStandardMaterial) + expect(ref.current!.children[0]).toBe(child.current) + expect(ref.current!.userData.attach).toBe(attachedChild.current) + + // Throw on non-array value + await expectToThrow( + // @ts-expect-error + async () => await act(async () => root.render()), + ) + + // Set + const geometry1 = new THREE.BoxGeometry() + const material1 = new THREE.MeshStandardMaterial() + await act(async () => root.render()) + expect(ref.current!.geometry).toBe(geometry1) + expect(ref.current!.material).toBe(material1) + expect(ref.current!.children[0]).toBe(child.current) + expect(ref.current!.userData.attach).toBe(attachedChild.current) + + // Update + const geometry2 = new THREE.BoxGeometry() + const material2 = new THREE.MeshStandardMaterial() + await act(async () => root.render()) + expect(ref.current!.geometry).toBe(geometry2) + expect(ref.current!.material).toBe(material2) + expect(ref.current!.children[0]).toBe(child.current) + expect(ref.current!.userData.attach).toBe(attachedChild.current) + + // Unset + await act(async () => root.render()) + expect(ref.current!.geometry).toBeInstanceOf(THREE.BufferGeometry) + expect(ref.current!.geometry).not.toBeInstanceOf(THREE.BoxGeometry) + expect(ref.current!.material).toBeInstanceOf(THREE.Material) + expect(ref.current!.material).not.toBeInstanceOf(THREE.MeshStandardMaterial) + expect(ref.current!.children[0]).toBe(child.current) + expect(ref.current!.userData.attach).toBe(attachedChild.current) + }) + + it('should handle the object prop reactively', async () => { + const ref = React.createRef() + const child = React.createRef() + const attachedChild = React.createRef() + + const Test = (props: JSX.IntrinsicElements['primitive']) => ( + + + + + ) + + const object1 = new THREE.Object3D() + const child1 = new THREE.Object3D() + object1.add(child1) + + const object2 = new THREE.Object3D() + const child2 = new THREE.Object3D() + object2.add(child2) + + // Initial + await act(async () => root.render()) + expect(ref.current).toBe(object1) + expect(ref.current!.children[0]).toBe(child1) + expect(ref.current!.children[1]).toBe(child.current) + expect(ref.current!.userData.attach).toBe(attachedChild.current) + + // Throw on undefined + await expectToThrow( + // @ts-expect-error + async () => await act(async () => root.render()), + ) + + // Update + await act(async () => root.render()) + expect(ref.current).toBe(object2) + expect(ref.current!.children[0]).toBe(child2) + expect(ref.current!.children[1]).toBe(child.current) + expect(ref.current!.userData.attach).toBe(attachedChild.current) + + // Revert + await act(async () => root.render()) + expect(ref.current).toBe(object1) + expect(ref.current!.children[0]).toBe(child1) + expect(ref.current!.children[1]).toBe(child.current) + expect(ref.current!.userData.attach).toBe(attachedChild.current) + }) + it('should handle unmount', async () => { const dispose = jest.fn() const childDispose = jest.fn() @@ -184,7 +336,8 @@ describe('renderer', () => { ref={(self: any) => { if (!self) return self.dispose = dispose - }}> + }} + onClick={() => void 0}> { if (!self) return @@ -219,10 +372,12 @@ describe('renderer', () => { const store = await act(async () => root.render()) await act(async () => root.render(null)) - const { scene } = store.getState() + const { scene, internal } = store.getState() // Cleans up scene-graph expect(scene.children.length).toBe(0) + // Removes events + expect(internal.interaction.length).toBe(0) // Calls dispose on top-level instance expect(dispose).toBeCalled() // Also disposes of children @@ -241,352 +396,6 @@ describe('renderer', () => { expect(disposeDeclarativePrimitive).toBeCalled() }) - it('updates types & names', async () => { - const store = await act(async () => - root.render( - - - - - , - ), - ) - const { scene } = store.getState() - - const basic = scene.children[0] as ComponentMesh - expect(basic.material).toBeInstanceOf(THREE.MeshBasicMaterial) - expect(basic.material.name).toBe('basicMat') - expect(basic.material.color.toArray()).toStrictEqual([0, 0, 0]) - - await act(async () => - root.render( - - - - - , - ), - ) - - const standard = scene.children[0] as ComponentMesh - expect(standard.material).toBeInstanceOf(THREE.MeshStandardMaterial) - expect(standard.material.name).toBe('standardMat') - expect(standard.material.color.toArray()).toStrictEqual([255, 255, 255]) - }) - - it('will mount/unmount event handlers correctly', async () => { - let state: RootState = null! - let mounted = false - let attachEvents = false - - const EventfulComponent = () => (mounted ? void 0 : undefined} /> : null) - - // Test initial mount without events - mounted = true - await act(async () => { - state = root.render().getState() - }) - expect(state.internal.interaction.length).toBe(0) - - // Test initial mount with events - attachEvents = true - await act(async () => { - state = root.render().getState() - }) - expect(state.internal.interaction.length).not.toBe(0) - - // Test events update - attachEvents = false - await act(async () => { - state = root.render().getState() - }) - expect(state.internal.interaction.length).toBe(0) - - attachEvents = true - await act(async () => { - state = root.render().getState() - }) - expect(state.internal.interaction.length).not.toBe(0) - - // Test unmount with events - mounted = false - await act(async () => { - state = root.render().getState() - }) - expect(state.internal.interaction.length).toBe(0) - }) - - it('will create an identical instance when reconstructing', async () => { - const instances: { uuid: string; parentUUID?: string; childUUID?: string }[] = [] - - const object1 = new THREE.Group() - const object2 = new THREE.Group() - - const Test = ({ first }: { first?: boolean }) => ( - null}> - - - ) - - const store = await act(async () => root.render()) - const { scene, internal } = store.getState() - - instances.push({ - uuid: scene.children[0].uuid, - parentUUID: scene.children[0].parent?.uuid, - childUUID: scene.children[0].children[0]?.uuid, - }) - expect(scene.children[0]).toBe(object1) - - await act(async () => root.render()) - - instances.push({ - uuid: scene.children[0].uuid, - parentUUID: scene.children[0].parent?.uuid, - childUUID: scene.children[0].children[0]?.uuid, - }) - - const [oldInstance, newInstance] = instances - - // Swapped to new instance - expect(scene.children[0]).toBe(object2) - - // Preserves scene hierarchy - expect(oldInstance.parentUUID).toBe(newInstance.parentUUID) - expect(oldInstance.childUUID).toBe(newInstance.childUUID) - - // Rebinds events - expect(internal.interaction.length).not.toBe(0) - }) - - it('can swap primitives', async () => { - const o1 = new THREE.Group() - o1.add(new THREE.Group()) - const o2 = new THREE.Group() - - const Test = ({ n }: { n: number }) => ( - - - - ) - - const store = await act(async () => root.render()) - const { scene } = store.getState() - - // Initial object is added with children and attachments - expect(scene.children[0]).toBe(o1) - expect(scene.children[0].children.length).toBe(1) - expect((scene.children[0] as any).test).toBeInstanceOf(THREE.Group) - - await act(async () => root.render()) - - // Swapped to object 2, does not copy old children, copies attachments - expect(scene.children[0]).toBe(o2) - expect(scene.children[0].children.length).toBe(0) - expect((scene.children[0] as any).test).toBeInstanceOf(THREE.Group) - }) - - it('will make an Orthographic Camera & set the position', async () => { - const store = await act(async () => - root.configure({ orthographic: true, camera: { position: [0, 0, 5] } }).render(), - ) - const { camera } = store.getState() - - expect(camera).toBeInstanceOf(THREE.OrthographicCamera) - expect(camera.position.z).toEqual(5) - }) - - it('should handle an performance changing functions', async () => { - let state: UseBoundStore = null! - await act(async () => { - state = root.configure({ dpr: [1, 2], performance: { min: 0.2 } }).render() - }) - - expect(state.getState().viewport.initialDpr).toEqual(window.devicePixelRatio) - expect(state.getState().performance.min).toEqual(0.2) - expect(state.getState().performance.current).toEqual(1) - - await act(async () => { - state.getState().setDpr(0.1) - }) - - expect(state.getState().viewport.dpr).toEqual(0.1) - - jest.useFakeTimers() - - await act(async () => { - state.getState().performance.regress() - jest.advanceTimersByTime(100) - }) - - expect(state.getState().performance.current).toEqual(0.2) - - await act(async () => { - jest.advanceTimersByTime(200) - }) - - expect(state.getState().performance.current).toEqual(1) - - jest.useRealTimers() - }) - - it('should set PCFSoftShadowMap as the default shadow map', async () => { - const store = await act(async () => root.configure({ shadows: true }).render()) - const { gl } = store.getState() - - expect(gl.shadowMap.type).toBe(THREE.PCFSoftShadowMap) - }) - - it('should set tonemapping to ACESFilmicToneMapping and outputEncoding to sRGBEncoding if linear is false', async () => { - const store = await act(async () => root.configure({ linear: false }).render()) - const { gl } = store.getState() - - expect(gl.toneMapping).toBe(THREE.ACESFilmicToneMapping) - expect(gl.outputEncoding).toBe(THREE.sRGBEncoding) - }) - - it('should toggle render mode in xr', async () => { - let state: RootState = null! - - await act(async () => { - state = root.render().getState() - state.gl.xr.isPresenting = true - state.gl.xr.dispatchEvent({ type: 'sessionstart' }) - }) - - expect(state.gl.xr.enabled).toEqual(true) - - await act(async () => { - state.gl.xr.isPresenting = false - state.gl.xr.dispatchEvent({ type: 'sessionend' }) - }) - - expect(state.gl.xr.enabled).toEqual(false) - }) - - it('should respect frameloop="never" in xr', async () => { - let respected = true - - const Test = () => useFrame(() => (respected = false)) - - await act(async () => { - const state = root - .configure({ frameloop: 'never' }) - .render() - .getState() - state.gl.xr.isPresenting = true - state.gl.xr.dispatchEvent({ type: 'sessionstart' }) - }) - - expect(respected).toEqual(true) - }) - - it('should set renderer props via gl prop', async () => { - const store = await act(async () => root.configure({ gl: { physicallyCorrectLights: true } }).render()) - const { gl } = store.getState() - - expect(gl.physicallyCorrectLights).toBe(true) - }) - - it('should set a renderer via gl callback', async () => { - class Renderer extends THREE.WebGLRenderer {} - - const store = await act(async () => root.configure({ gl: (canvas) => new Renderer({ canvas }) }).render()) - const { gl } = store.getState() - - expect(gl instanceof Renderer).toBe(true) - }) - - it('should respect color management preferences via gl', async () => { - const store = await act(async () => - root - .configure({ gl: { outputEncoding: THREE.LinearEncoding, toneMapping: THREE.NoToneMapping } }) - .render(), - ) - const { gl } = store.getState() - - expect(gl.outputEncoding).toBe(THREE.LinearEncoding) - expect(gl.toneMapping).toBe(THREE.NoToneMapping) - - await act(async () => root.configure({ flat: true, linear: true }).render()) - expect(gl.outputEncoding).toBe(THREE.LinearEncoding) - expect(gl.toneMapping).toBe(THREE.NoToneMapping) - }) - - it('should respect legacy prop', async () => { - await act(async () => root.configure({ legacy: true }).render()) - expect((THREE as any).ColorManagement.legacyMode).toBe(true) - - await act(async () => root.configure({ legacy: false }).render()) - expect((THREE as any).ColorManagement.legacyMode).toBe(false) - }) - - it('can handle createPortal', async () => { - const scene = new THREE.Scene() - - let state: RootState = null! - let portalState: RootState = null! - - const Normal = () => { - const three = useThree() - state = three - - return - } - - const Portal = () => { - const three = useThree() - portalState = three - - return - } - - await act(async () => { - root.render( - <> - - {createPortal(, scene, { scene })} - , - ) - }) - - // Renders into portal target - expect(scene.children.length).not.toBe(0) - - // Creates an isolated state enclave - expect(state.scene).not.toBe(scene) - expect(portalState.scene).toBe(scene) - - // Preserves internal keys - const overwrittenKeys = ['get', 'set', 'events', 'size', 'viewport'] - const respectedKeys = privateKeys.filter((key) => overwrittenKeys.includes(key) || state[key] === portalState[key]) - expect(respectedKeys).toStrictEqual(privateKeys) - }) - - it('can handle createPortal on unmounted container', async () => { - let groupHandle!: THREE.Group | null - function Test(props: any) { - const [group, setGroup] = React.useState(null) - groupHandle = group - - return ( - - {group && createPortal(, group)} - - ) - } - - await act(async () => root.render()) - - expect(groupHandle).toBeDefined() - const prevUUID = groupHandle!.uuid - - await act(async () => root.render()) - - expect(groupHandle).toBeDefined() - expect(prevUUID).not.toBe(groupHandle!.uuid) - }) - it('should gracefully handle text', async () => { const warn = console.warn console.warn = jest.fn() @@ -618,7 +427,7 @@ describe('renderer', () => { void (lastMounted = self?.uuid)} - attach={(parent, self) => { + attach={(_, self) => { calls.push('attach') lastAttached = self.uuid return () => calls.push('detach') From d51a426cee7a603bc5c470dd7bcd4814cab925b5 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 08:34:16 -0500 Subject: [PATCH 51/58] chore(tests): cleanup --- packages/fiber/tests/renderer.test.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/fiber/tests/renderer.test.tsx b/packages/fiber/tests/renderer.test.tsx index e1465ae2be..e6aec2d8a1 100644 --- a/packages/fiber/tests/renderer.test.tsx +++ b/packages/fiber/tests/renderer.test.tsx @@ -290,8 +290,7 @@ describe('renderer', () => { // Initial await act(async () => root.render()) expect(ref.current).toBe(object1) - expect(ref.current!.children[0]).toBe(child1) - expect(ref.current!.children[1]).toBe(child.current) + expect(ref.current!.children).toStrictEqual([child1, child.current]) expect(ref.current!.userData.attach).toBe(attachedChild.current) // Throw on undefined @@ -303,15 +302,13 @@ describe('renderer', () => { // Update await act(async () => root.render()) expect(ref.current).toBe(object2) - expect(ref.current!.children[0]).toBe(child2) - expect(ref.current!.children[1]).toBe(child.current) + expect(ref.current!.children).toStrictEqual([child2, child.current]) expect(ref.current!.userData.attach).toBe(attachedChild.current) // Revert await act(async () => root.render()) expect(ref.current).toBe(object1) - expect(ref.current!.children[0]).toBe(child1) - expect(ref.current!.children[1]).toBe(child.current) + expect(ref.current!.children).toStrictEqual([child1, child.current]) expect(ref.current!.userData.attach).toBe(attachedChild.current) }) From 78f0c53ebfc7e120748a80a70e11b729468f34d6 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 09:06:36 -0500 Subject: [PATCH 52/58] chore: cleanup removeChild --- packages/fiber/src/core/renderer.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index f6ea688947..ab2e0bedf4 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -190,30 +190,25 @@ function removeChild( removeInteractivity(child.root, child.object) } - // 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 + // Allow objects to bail out of unmount disposal with dispose={null} const shouldDispose = child.props.dispose !== null && dispose !== false - // Remove nested child objects. Primitives should not have objects and children that are - // attached to them declaratively ... + // Recursively remove instance children if (recursive !== false) { - for (const childInstance of child.children) { - removeChild(child, childInstance, shouldDispose, true) - } + for (const node of child.children) removeChild(child, node, shouldDispose, true) + child.children = [] } // Unlink instance object delete child.object.__r3f - // Dispose object whenever the reconciler feels like it - if (child.type !== 'primitive' && child.object.type !== 'Scene' && shouldDispose) { + // Dispose object whenever the reconciler feels like it. + // 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 a dispose method + // - cannot be a + // - cannot be a THREE.Scene, because three has broken its own API + if (shouldDispose && child.type !== 'primitive' && child.object.type !== 'Scene') { const dispose = child.object.dispose if (typeof dispose === 'function') { scheduleCallback(idlePriority, () => { From 23f250d54d26160d05f2517ebc00883e62065ab7 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 11:19:30 -0500 Subject: [PATCH 53/58] fix(applyProps): loosen literal check --- packages/fiber/src/core/utils.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 4c4a8292d8..3170f6f0c4 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -331,16 +331,16 @@ export function applyProps(object: Instance['object'], props: Instan } // If nothing else fits, just set the single value, ignore undefined // https://github.com/pmndrs/react-three-fiber/issues/274 - else if (typeof value === 'number' || value instanceof THREE.Layers) { + else if (value !== undefined) { const isColor = target instanceof THREE.Color - // Allow setting array scalars - if (!isColor && target.setScalar) target.setScalar(value) // Layers have no copy function, we must therefore copy the mask property - else if (target instanceof THREE.Layers && value instanceof THREE.Layers) target.mask = value.mask + if (target instanceof THREE.Layers && value instanceof THREE.Layers) target.mask = value.mask + // Overwrite atomic properties + else if (typeof value === 'object') root[key] = value + // Allow setting array scalars + else if (!isColor && target.setScalar) target.setScalar(value) // Otherwise just set ... else target.set(value) - } else if (value !== undefined) { - root[key] = value } // Else, just overwrite the value } else { From e311e86852216f1978f70c1953a1d6805687657e Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 12:27:17 -0500 Subject: [PATCH 54/58] chore(applyProps): cleanup --- packages/fiber/src/core/utils.ts | 55 ++++++++++++++------------------ 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 3170f6f0c4..f335bc3896 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -313,37 +313,30 @@ export function applyProps(object: Instance['object'], props: Instan } } - // Special treatment for objects with support for set/copy, and layers - if (target && target.set && (target.copy || target instanceof THREE.Layers)) { - // If value is an array - if (Array.isArray(value)) { - if (target.fromArray) target.fromArray(value) - else target.set(...value) - } - // Test again target.copy(class) next ... - else if ( - target.copy && - value && - (value as ConstructorRepresentation).constructor && - target.constructor.name === (value as ConstructorRepresentation).constructor.name - ) { - 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 = target instanceof THREE.Color - // Layers have no copy function, we must therefore copy the mask property - if (target instanceof THREE.Layers && value instanceof THREE.Layers) target.mask = value.mask - // Overwrite atomic properties - else if (typeof value === 'object') root[key] = value - // Allow setting array scalars - else if (!isColor && target.setScalar) target.setScalar(value) - // Otherwise just set ... - else target.set(value) - } - // Else, just overwrite the value - } else { + // Copy if properties match signatures + if (target?.constructor === (value as ConstructorRepresentation)?.constructor && target.copy) { + target.copy(value) + } + // Layers have no copy function, we must therefore copy the mask property + else if (target instanceof THREE.Layers && value instanceof THREE.Layers) { + target.mask = value.mask + } + // Set array types + else if (target?.set && Array.isArray(value)) { + if (target.fromArray) target.fromArray(value) + else target.set(...value) + } + // Set literal types, ignore undefined + // https://github.com/pmndrs/react-three-fiber/issues/274 + else if (target?.set && typeof value !== 'object') { + const isColor = target instanceof THREE.Color + // Allow setting array scalars + if (!isColor && target.setScalar) target.setScalar(value) + // Otherwise just set ... + else if (value !== undefined) target.set(value) + } + // Else, just overwrite the value + else { root[key] = value // Auto-convert sRGB textures, for now ... // https://github.com/pmndrs/react-three-fiber/issues/344 From 54319d2fb1c97412a695a09fa47b042b05c12cd9 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 18:43:59 -0500 Subject: [PATCH 55/58] fix(applyProps): validate scalar type --- packages/fiber/src/core/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index f335bc3896..722925e051 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -331,7 +331,7 @@ export function applyProps(object: Instance['object'], props: Instan else if (target?.set && typeof value !== 'object') { const isColor = target instanceof THREE.Color // Allow setting array scalars - if (!isColor && target.setScalar) target.setScalar(value) + if (!isColor && target.setScalar && typeof value === 'number') target.setScalar(value) // Otherwise just set ... else if (value !== undefined) target.set(value) } From 870b6db13047c5ec46e01857e406c8affb407d49 Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sat, 10 Sep 2022 18:52:06 -0500 Subject: [PATCH 56/58] chore(tests): add undefined, layers applyProps cases --- packages/fiber/tests/utils.test.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/fiber/tests/utils.test.ts b/packages/fiber/tests/utils.test.ts index 10c472098e..d003118a81 100644 --- a/packages/fiber/tests/utils.test.ts +++ b/packages/fiber/tests/utils.test.ts @@ -343,14 +343,21 @@ describe('applyProps', () => { const color = new THREE.Color() color.copy = jest.fn() - const target = { color } + const target = { color, layer: new THREE.Layers() } - // Same constructor + // Same constructor, copy applyProps(target, { color: new THREE.Color() }) expect(target.color).toBeInstanceOf(THREE.Color) expect(color.copy).toHaveBeenCalledTimes(1) - // Different constructor + // Same constructor, Layers + const layer = new THREE.Layers() + layer.mask = 5 + applyProps(target, { layer }) + expect(target.layer).toBeInstanceOf(THREE.Layers) + expect(target.layer.mask).toBe(layer.mask) + + // Different constructor, overwrite applyProps(target, { color: new THREE.Vector3() }) expect(target.color).toBeInstanceOf(THREE.Vector3) expect(color.copy).toHaveBeenCalledTimes(1) @@ -364,15 +371,20 @@ describe('applyProps', () => { }) it('should set with scalar shorthand where applicable', async () => { + // Vector3#setScalar const target = new THREE.Object3D() applyProps(target, { scale: 5 }) - expect(target.scale.toArray()).toStrictEqual([5, 5, 5]) + // Color#set const material = new THREE.MeshBasicMaterial() applyProps(material, { color: 0x000000 }) - expect(material.color.getHex()).toBe(0x000000) + + // No-op on undefined + const mesh = new THREE.Mesh() + applyProps(mesh, { position: undefined }) + expect(mesh.position.toArray()).toStrictEqual([0, 0, 0]) }) it('should pierce into nested properties', () => { From b5081a69a58f9a86d641dac9651b32daffe8cbbe Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Sun, 11 Sep 2022 21:30:15 -0500 Subject: [PATCH 57/58] refactor(utils): move HMR to diffProps --- packages/fiber/src/core/renderer.ts | 2 +- packages/fiber/src/core/utils.ts | 62 ++++++++++++++--------------- packages/fiber/tests/utils.test.ts | 57 +++++++++++++------------- 3 files changed, 59 insertions(+), 62 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index ab2e0bedf4..988e6b12c3 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -329,7 +329,7 @@ const reconciler = Reconciler< 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(oldProps, newProps, true) + const changedProps = diffProps(instance, newProps, true) if (Object.keys(changedProps).length) return [false, changedProps] // Otherwise do not touch the instance diff --git a/packages/fiber/src/core/utils.ts b/packages/fiber/src/core/utils.ts index 722925e051..b1a914ede7 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -231,7 +231,6 @@ export function detach(parent: Instance, child: Instance): void { delete child.previousAttach } -export const DEFAULT = '__default' export const RESERVED_PROPS = [ ...REACT_INTERNAL_PROPS, // Instance props @@ -245,28 +244,46 @@ export const RESERVED_PROPS = [ // This function prepares a set of changes to be applied to the instance export function diffProps( - oldProps: Instance['props'], + instance: Instance, newProps: Instance['props'], - remove = false, + resetRemoved = false, ): Instance['props'] { const changedProps: Instance['props'] = {} // Sort through props - for (const key in newProps) { + for (const prop in newProps) { // Skip reserved keys - if (RESERVED_PROPS.includes(key)) continue + if (RESERVED_PROPS.includes(prop)) continue // Skip if props match - if (is.equ(newProps[key], oldProps[key])) continue + if (is.equ(newProps[prop], instance.props[prop])) continue // Props changed, add them - changedProps[key] = newProps[key] + changedProps[prop] = newProps[prop] } - // Catch removed props, prepend them so they can be reset or removed - if (remove) { - for (const key in oldProps) { - if (RESERVED_PROPS.includes(key)) continue - else if (!newProps.hasOwnProperty(key)) changedProps[key] = DEFAULT + 'remove' + // Reset removed props for HMR + if (resetRemoved) { + for (const prop in instance.props) { + if (RESERVED_PROPS.includes(prop) || newProps.hasOwnProperty(prop)) continue + + const { root, key } = resolve(instance.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 (root.constructor) { + // create a blank slate of the instance and copy the particular parameter. + // @ts-ignore + const defaultClassCall = new root.constructor(...(root.__r3f?.props.args ?? [])) + changedProps[key] = defaultClassCall[key] + // destroy the instance + if (defaultClassCall.dispose) defaultClassCall.dispose() + } else { + // instance does not have constructor, just set it to 0 + changedProps[key] = 0 + } } } @@ -294,27 +311,8 @@ export function applyProps(object: Instance['object'], props: Instan 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 (root.constructor) { - // create a blank slate of the instance and copy the particular parameter. - // @ts-ignore - const defaultClassCall = new root.constructor(...(root.__r3f?.props.args ?? [])) - value = defaultClassCall[key] - // destroy the instance - if (defaultClassCall.dispose) defaultClassCall.dispose() - } else { - // instance does not have constructor, just set it to 0 - value = 0 - } - } - // Copy if properties match signatures - if (target?.constructor === (value as ConstructorRepresentation)?.constructor && target.copy) { + if (target?.copy && target?.constructor === (value as ConstructorRepresentation)?.constructor) { target.copy(value) } // Layers have no copy function, we must therefore copy the mask property diff --git a/packages/fiber/tests/utils.test.ts b/packages/fiber/tests/utils.test.ts index d003118a81..2a10d8744c 100644 --- a/packages/fiber/tests/utils.test.ts +++ b/packages/fiber/tests/utils.test.ts @@ -12,7 +12,6 @@ import { detach, RESERVED_PROPS, diffProps, - DEFAULT, applyProps, updateCamera, } from '../src/core/utils' @@ -256,19 +255,40 @@ describe('attach / detach', () => { describe('diffProps', () => { it('should filter changed props', () => { - const oldProps = { foo: true } + const instance = prepare({}, storeMock, '', { foo: true }) const newProps = { foo: true, bar: false } - const filtered = diffProps(oldProps, newProps) + const filtered = diffProps(instance, newProps) expect(filtered).toStrictEqual({ bar: false }) }) it('should pick removed props for HMR', () => { - const oldProps = { foo: true, bar: false } - const newProps = { foo: true } + const instance = prepare(new THREE.Object3D(), storeMock, '', { position: [0, 0, 1] }) + const newProps = {} - const filtered = diffProps(oldProps, newProps, true) - expect(filtered).toStrictEqual({ bar: DEFAULT + 'remove' }) + const filtered = diffProps(instance, newProps, true) + expect(filtered).toStrictEqual({ position: new THREE.Object3D().position }) + }) + + it('should reset removed props for HMR', () => { + class Target extends THREE.Object3D { + constructor(x = 0, y = 0, z = 0) { + super() + this.position.set(x, y, z) + } + } + + const target = new Target() + const instance = prepare(target, storeMock, '', { position: 10 }) + + // Recreate from scratch + let filtered = diffProps(instance, {}, true) + expect((filtered.position as THREE.Vector3).toArray()).toStrictEqual([0, 0, 0]) + + // Recreate from instance args + instance.props = { args: [1, 2, 3], position: 10 } + filtered = diffProps(instance, {}, true) + expect((filtered.position as THREE.Vector3).toArray()).toStrictEqual([1, 2, 3]) }) it('should filter reserved props without accessing them', () => { @@ -277,7 +297,7 @@ describe('diffProps', () => { const props = { foo: true } const filtered = diffProps( - {}, + prepare({}, storeMock, '', {}), RESERVED_PROPS.reduce((acc, key) => ({ ...acc, [key]: { get, set } }), props), ) @@ -309,27 +329,6 @@ describe('applyProps', () => { expect(set).not.toBeCalled() }) - it('should reset removed props for HMR', () => { - class Target extends THREE.Object3D { - constructor(x = 0, y = 0, z = 0) { - super() - this.position.set(x, y, z) - } - } - - const target = new Target() - target.position.setScalar(10) - - // Recreate from scratch - applyProps(target, { position: DEFAULT + 'remove' }) - expect(target.position.toArray()).toStrictEqual([0, 0, 0]) - - // Recreate from instance args - prepare(target, storeMock, '', { args: [1, 2, 3] }) - applyProps(target, { position: DEFAULT + 'remove' }) - expect(target.position.toArray()).toStrictEqual([1, 2, 3]) - }) - it('should overwrite non-atomic properties', () => { const foo = { value: true } const target = { foo } From 817f427334be23de0b36de8ad1ec02323e9f70dc Mon Sep 17 00:00:00 2001 From: Cody Bennett <23324155+CodyJasonBennett@users.noreply.github.com> Date: Mon, 12 Sep 2022 02:02:33 -0500 Subject: [PATCH 58/58] fix(core): replace old instance on reconstruct --- packages/fiber/src/core/renderer.ts | 8 +++++--- .../__snapshots__/RTTR.core.test.tsx.snap | 15 ++------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/fiber/src/core/renderer.ts b/packages/fiber/src/core/renderer.ts index 988e6b12c3..b5844453f2 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -141,12 +141,15 @@ function insertBefore( parent: HostConfig['instance'], child: HostConfig['instance'] | HostConfig['textInstance'], beforeChild: HostConfig['instance'] | HostConfig['textInstance'], + replace: boolean = false, ) { if (!child || !beforeChild) return // Link instances child.parent = parent - parent.children.splice(parent.children.indexOf(beforeChild), 0, child) + const childIndex = parent.children.indexOf(beforeChild) + if (childIndex !== -1) parent.children.splice(childIndex, replace ? 1 : 0, child) + if (replace) beforeChild.parent = null // Manually splice Object3Ds if ( @@ -244,8 +247,7 @@ function switchInstance( // Link up new instance const parent = oldInstance.parent if (parent) { - removeChild(parent, oldInstance, true, false) - appendChild(parent, newInstance) + insertBefore(parent, newInstance, oldInstance, true) } // This evil hack switches the react-internal fiber node 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 3df1a6374d..6832a2d939 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 @@ -390,8 +390,8 @@ Array [ "children": Array [], "props": Object { "args": Array [ - 6, - 6, + 2, + 2, ], "attach": "geometry", }, @@ -404,17 +404,6 @@ Array [ }, "type": "meshBasicMaterial", }, - Object { - "children": Array [], - "props": Object { - "args": Array [ - 2, - 2, - ], - "attach": "geometry", - }, - "type": "boxGeometry", - }, ], "props": Object { "position-x": 12,