diff --git a/docs/tutorials/typescript.mdx b/docs/tutorials/typescript.mdx index 4c57ca7bb2..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. @@ -89,26 +60,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 `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, Object3DNode } from '@react-three/fiber' +import { extend, ThreeElement } from '@react-three/fiber' // Create our custom element class CustomElement extends GridHelper {} @@ -119,7 +78,7 @@ extend({ CustomElement }) // Add types to ThreeElements elements so primitives pick up on it declare module '@react-three/fiber' { interface ThreeElements { - customElement: Object3DNode + customElement: ThreeElement } } 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..ded4d63081 100644 --- a/example/typings/global.d.ts +++ b/example/typings/global.d.ts @@ -1,11 +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.Node - dotMaterial: ReactThreeFiber.MaterialNode + orbitControls: ThreeElement + dotMaterial: ThreeElement } } diff --git a/packages/fiber/src/core/events.ts b/packages/fiber/src/core/events.ts index ccbb35ae4b..74ed4fb74c 100644 --- a/packages/fiber/src/core/events.ts +++ b/packages/fiber/src/core/events.ts @@ -176,7 +176,8 @@ 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 Instance['object']).__r3f?.handlers[('onPointer' + name) as keyof EventHandlers], ), ) } @@ -242,7 +243,8 @@ 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 Instance['object']).__r3f?.eventCount) + intersections.push({ ...hit, eventObject }) eventObject = eventObject.parent } } @@ -374,10 +376,10 @@ export function createEvents(store: UseBoundStore) { ) ) { const eventObject = hoveredObj.eventObject - const instance = (eventObject as unknown as Instance).__r3f - const handlers = instance?.handlers + const instance = (eventObject as Instance['object']).__r3f 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) @@ -439,10 +441,11 @@ export function createEvents(store: UseBoundStore) { handleIntersects(hits, event, delta, (data: ThreeEvent) => { const eventObject = data.eventObject - const instance = (eventObject as unknown as Instance).__r3f - const handlers = instance?.handlers + const instance = (eventObject as Instance['object']).__r3f + // Check presence of handlers if (!instance?.eventCount) return + const handlers = instance.handlers if (isPointerMove) { // Move event ... @@ -493,7 +496,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), + (object as Instance['object']).__r3f?.handlers.onPointerMissed?.(event), ) } 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 } diff --git a/packages/fiber/src/core/index.tsx b/packages/fiber/src/core/index.tsx index 0181a35375..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, @@ -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]> @@ -88,9 +88,9 @@ export type RenderProps = { camera?: ( | Camera | Partial< - ReactThreeFiber.Object3DNode & - ReactThreeFiber.Object3DNode & - ReactThreeFiber.Object3DNode + ThreeElement & + ThreeElement & + ThreeElement > ) & { /** Flags the camera as manual, putting projection into your own hands */ @@ -233,9 +233,9 @@ function createRoot(canvas: TCanvas): ReconcilerRoot(canvas: TCanvas): ReconcilerRoot(canvas: TCanvas): ReconcilerRoot(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 e064d172f2..b5844453f2 100644 --- a/packages/fiber/src/core/renderer.ts +++ b/packages/fiber/src/core/renderer.ts @@ -2,421 +2,399 @@ 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, prepare, diffProps, DiffSet, applyProps, invalidateInstance, attach, detach } from './utils' +import { is, diffProps, applyProps, invalidateInstance, attach, detach, prepare } from './utils' import { RootState } from './store' -import { EventHandlers, removeInteractivity } from './events' +import { removeInteractivity, getEventPriority, EventHandlers } from './events' -export type Root = { fiber: Reconciler.FiberRoot; store: UseBoundStore } +export interface Root { + fiber: Reconciler.FiberRoot + store: UseBoundStore +} -export type LocalState = { - type: string +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 interface InstanceProps { + args?: Args + object?: T + 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 & Record + object: O & { __r3f?: Instance } eventCount: number handlers: Partial - attach?: AttachType - previousAttach: any - memoizedProps: { [key: string]: any } + attach?: AttachType + previousAttach?: any + isHidden: boolean } -export type AttachFnType = (parent: Instance, self: Instance) => () => void -export type AttachType = string | AttachFnType - interface HostConfig { type: string - props: InstanceProps + props: Instance['props'] container: UseBoundStore instance: Instance textInstance: void suspenseInstance: Instance - hydratableInstance: Instance - publicInstance: Instance + hydratableInstance: never + publicInstance: Instance['object'] hostContext: never - updatePayload: Array + updatePayload: null | [true] | [false, Instance['props']] 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 } +const catalogue: Catalogue = {} +const extend = (objects: Partial): void => void Object.assign(catalogue, objects) + +function createInstance( + type: string, + 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) + + // 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' + } -export type InstanceProps = { - [key: string]: unknown -} & { - args?: any[] - object?: object - visible?: boolean - dispose?: null - attach?: AttachType + // Set initial props + applyProps(instance.object, props) + + return instance } -interface Catalogue { - [name: string]: { - new (...args: any): 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) } -let catalogue: Catalogue = {} -let extend = (objects: object): void => void (catalogue = { ...catalogue, ...objects }) +function appendChild(parent: HostConfig['instance'], child: HostConfig['instance'] | HostConfig['textInstance']) { + if (!child) return -function createRenderer(_roots: Map, _getEventPriority?: () => any) { - function createInstance( - type: string, - { args = [], attach, ...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`, - ) - } + // Link instances + child.parent = parent + parent.children.push(child) - // 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 }, - }) - } + // Add Object3Ds if able + if (!child.props.attach && parent.object instanceof THREE.Object3D && child.object instanceof THREE.Object3D) { + parent.object.add(child.object) + } - // 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' - } + // Attach tree once complete + handleContainerEffects(parent, child) - // It should NOT call onUpdate on object instanciation, because it hasn't been added to the - // view yet. If the callback relies on references for instance, they won't be ready yet, this is - // why it passes "true" here - // There is no reason to apply props to injects - if (name !== 'inject') applyProps(instance, props) - return instance - } + // Tree was updated, request a frame + invalidateInstance(child) +} - 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 - invalidateInstance(child) - } +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 + 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 ( + !child.props.attach && + 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' }) } - function insertBefore( - parentInstance: HostConfig['instance'], - child: HostConfig['instance'], - beforeChild: HostConfig['instance'], - ) { - 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 - } + // Attach tree once complete + handleContainerEffects(parent, child) - if (!added) parentInstance.__r3f?.objects.push(child) - if (!child.__r3f) prepare(child, {}) - child.__r3f.parent = parentInstance - invalidateInstance(child) - } - } + // Tree was updated, request a frame + invalidateInstance(child) +} - function removeRecursive(array: HostConfig['instance'][], parent: HostConfig['instance'], dispose: boolean = false) { - if (array) [...array].forEach((child) => removeChild(parent, child, dispose)) +function removeChild( + parent: HostConfig['instance'], + child: HostConfig['instance'] | HostConfig['textInstance'], + dispose?: boolean, + recursive?: boolean, +) { + if (!child) return + + // Unlink instances + child.parent = null + if (recursive === undefined) { + const childIndex = parent.children.indexOf(child) + if (childIndex !== -1) parent.children.splice(childIndex, 1) } - 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) - } - } - - // 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) - } + // Eagerly tear down tree + 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) + } - // 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 - } + // Allow objects to bail out of unmount disposal with dispose={null} + const shouldDispose = child.props.dispose !== null && dispose !== false - // Dispose item whenever the reconciler feels like it - if (shouldDispose && child.dispose && child.type !== 'Scene') { - scheduleCallback(idlePriority, () => { - try { - child.dispose() - } catch (e) { - /* ... */ - } - }) - } + // Recursively remove instance children + if (recursive !== false) { + for (const node of child.children) removeChild(child, node, shouldDispose, true) + child.children = [] + } - invalidateInstance(parentInstance) + // Unlink instance object + delete child.object.__r3f + + // 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, () => { + try { + dispose() + } catch (e) { + /* ... */ + } + }) } } - function switchInstance( - instance: HostConfig['instance'], - type: HostConfig['type'], - newProps: HostConfig['props'], - fiber: Reconciler.Fiber, - ) { - const parent = instance.__r3f?.parent - if (!parent) return + // Tree was updated, request a frame for top-level instance + if (dispose === undefined) invalidateInstance(child) +} + +function switchInstance( + oldInstance: HostConfig['instance'], + type: HostConfig['type'], + props: HostConfig['props'], + fiber: Reconciler.Fiber, +) { + // Create a new instance + const newInstance = createInstance(type, props, oldInstance.root) + + // Move children to new instance + for (const child of oldInstance.children) { + removeChild(oldInstance, child, false, false) + appendChild(newInstance, child) + } + oldInstance.children = [] - const newInstance = createInstance(type, newProps, instance.__r3f.root) + // Link up new instance + const parent = oldInstance.parent + if (parent) { + insertBefore(parent, newInstance, oldInstance, true) + } - // 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) + // 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.ref(newInstance.object) + else fiber.ref.current = newInstance.object } - instance.children = instance.children.filter((child) => !child.__r3f) } + }) - instance.__r3f.objects.forEach((child) => appendChild(newInstance, child)) - instance.__r3f.objects = [] + // Tree was updated, request a frame + invalidateInstance(newInstance) - removeChild(parent, instance) - appendChild(parent, newInstance) + return newInstance +} - // 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) +// 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) { + const scene = (container.getState().scene as unknown as Instance['object']).__r3f + if (!child || !scene) return + + appendChild(scene, child) + }, + removeChildFromContainer(container, child) { + const scene = (container.getState().scene as unknown as Instance['object']).__r3f + if (!child || !scene) return + + removeChild(scene, child) + }, + insertInContainerBefore(container, 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, + prepareUpdate(instance, _type, oldProps, newProps) { + // 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] + + // Create a diff-set, flag if there are any changes + const changedProps = diffProps(instance, newProps, 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 false, + commitMount() {}, + 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) - else (fiber.ref as Reconciler.RefObject).current = newInstance - } + instance.isHidden = true + invalidateInstance(instance) + }, + unhideInstance(instance) { + 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 } - }) - } - - // 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.') - - 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'] - >({ - createInstance, - removeChild, - appendChild, - appendInitialChild: appendChild, - insertBefore, - supportsMutation: true, - isPrimaryRenderer: false, - supportsPersistence: false, - supportsHydration: false, - noTimeout: -1, - appendChildToContainer: (container, child) => { - if (!child) return - - // Don't append to unmounted container - const scene = container.getState().scene as unknown as Instance - if (!scene.__r3f) return - - // Link current root to the default scene - scene.__r3f.root = container - appendChild(scene, child) - }, - removeChildFromContainer: (container, child) => { - if (!child) return - removeChild(container.getState().scene as unknown as Instance, child) - }, - insertInContainerBefore: (container, child, beforeChild) => { - if (!child || !beforeChild) return - - // Don't append to unmounted container - const scene = container.getState().scene as unknown as Instance - if (!scene.__r3f) return - - insertBefore(scene, 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 - } - }, - commitUpdate(instance, [reconstruct, diff]: [boolean, DiffSet], type, _oldProps, newProps, fiber) { - // Reconstruct when args or instance!, - prepareForCommit: () => null, - preparePortalMount: (container) => prepare(container.getState().scene), - 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 - 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 - 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 } -} + } -export { prepare, createRenderer, extend } + instance.isHidden = false + 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/store.ts b/packages/fiber/src/core/store.ts index 79df281c64..042313cb38 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 @@ -168,7 +167,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() @@ -215,7 +214,7 @@ const createStore = ( legacy: false, linear: false, flat: false, - scene: prepare(new THREE.Scene()), + scene: new THREE.Scene(), controls: null, clock: new THREE.Clock(), @@ -351,13 +350,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) { @@ -380,10 +381,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 90d7ec0ef0..71d8e38b71 100644 --- a/packages/fiber/src/core/utils.ts +++ b/packages/fiber/src/core/utils.ts @@ -1,9 +1,10 @@ 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 { Dpr, RootState, Size } from './store' +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 { ConstructorRepresentation, Instance } from './renderer' export type Camera = THREE.OrthographicCamera | THREE.PerspectiveCamera export const isOrthographicCamera = (def: Camera): def is THREE.OrthographicCamera => @@ -24,7 +25,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 @@ -55,22 +56,12 @@ 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 = { +export interface ObjectMap { nodes: { [name: string]: THREE.Object3D } materials: { [name: string]: THREE.Material } } -export function calculateDpr(dpr: Dpr) { +export function calculateDpr(dpr: Dpr): number { const target = typeof window !== 'undefined' ? window.devicePixelRatio : 1 return Array.isArray(dpr) ? Math.min(Math.max(dpr[0], target), dpr[1]) : dpr } @@ -78,10 +69,10 @@ 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() +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 */ @@ -124,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) => { @@ -135,225 +126,251 @@ export function buildGraph(object: THREE.Object3D) { return data } +export 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?.() - delete obj[p] + const prop = obj[p] as Disposable | undefined + if (prop?.type !== 'Scene') prop?.dispose?.() + } +} + +export const REACT_INTERNAL_PROPS = ['children', 'key', 'ref'] + +// Gets only instance props from reconciler fibers +export 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 } // 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: {}, +export function prepare( + target: T, + root: UseBoundStore, + type: string, + props: Instance['props'], +): Instance { + const object = target as unknown as Instance['object'] + + // Create instance descriptor + let instance = object.__r3f + if (!instance) { + instance = { + root, + type, + parent: null, + children: [], + props: getInstanceProps(props), + object, eventCount: 0, handlers: {}, - objects: [], - parent: null, - ...state, + isHidden: false, } + object.__r3f = instance } - return object + + 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 } +export function resolve(root: any, key: string): { root: any; key: string; target: any } { + 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): void { + 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): void { + 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 } +export const RESERVED_PROPS = [ + ...REACT_INTERNAL_PROPS, + // Instance props + 'args', + 'dispose', + 'attach', + 'object', + // Behavior flags + 'dispose', +] + // 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[]][] = [] - - // 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']) - } +export function diffProps( + instance: Instance, + newProps: Instance['props'], + resetRemoved = false, +): Instance['props'] { + const changedProps: Instance['props'] = {} + + // Sort through props + for (const prop in newProps) { + // Skip reserved keys + if (RESERVED_PROPS.includes(prop)) continue + // Skip if props match + if (is.equ(newProps[prop], instance.props[prop])) continue + + // Props changed, add them + changedProps[prop] = newProps[prop] } - 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 } -} + // Reset removed props for HMR + if (resetRemoved) { + for (const prop in instance.props) { + if (RESERVED_PROPS.includes(prop) || newProps.hasOwnProperty(prop)) continue -// 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 - } - } + 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 (value === DEFAULT + 'remove') { - if (targetProp && targetProp.constructor) { - // use the prop constructor to find the default it should be - value = new targetProp.constructor(...(memoized.args ?? [])) - } else if (currentInstance.constructor) { + // 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 currentInstance.constructor(...(currentInstance.__r3f.memoizedProps.args ?? [])) - value = defaultClassCall[targetProp] - // destory the instance + const defaultClassCall = new root.constructor(...(root.__r3f?.props.args ?? [])) + changedProps[key] = defaultClassCall[key] + // destroy the instance if (defaultClassCall.dispose) defaultClassCall.dispose() - // instance does not have constructor, just set it to 0 } else { - value = 0 + // instance does not have constructor, just set it to 0 + changedProps[key] = 0 } } + } + + return changedProps +} + +// This function applies a set of changes to the instance +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[prop] + + // Don't mutate reserved keys + if (RESERVED_PROPS.includes(prop)) continue // 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 + if (instance && /^on(Pointer|Click|DoubleClick|ContextMenu|Wheel)/.test(prop)) { + 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 } - // Special treatment for objects with support for set/copy, and layers - else if (targetProp && targetProp.set && (targetProp.copy || targetProp instanceof THREE.Layers)) { - // If value is an array - if (Array.isArray(value)) { - if (targetProp.fromArray) targetProp.fromArray(value) - else targetProp.set(...value) - } - // Test again target.copy(class) next ... - else if ( - targetProp.copy && - value && - (value as ClassConstructor).constructor && - targetProp.constructor.name === (value as ClassConstructor).constructor.name - ) { - targetProp.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 - // Allow setting array scalars - if (!isColor && targetProp.setScalar) targetProp.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 - // Otherwise just set ... - else targetProp.set(value) - } - // Else, just overwrite the value - } else { - currentInstance[key] = value + + const { root, key, target } = resolve(object, prop) + + // Copy if properties match signatures + if (target?.copy && target?.constructor === (value as ConstructorRepresentation)?.constructor) { + 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 && typeof value === 'number') 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 - 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.object.raycast !== null && instance.object instanceof THREE.Object3D) { + rootState.internal.interaction.push(instance.object) + } } - return instance + if (instance) invalidateInstance(instance) + + return object } -export function invalidateInstance(instance: Instance) { - const state = instance.__r3f?.root?.getState?.() +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) { diff --git a/packages/fiber/src/index.tsx b/packages/fiber/src/index.tsx index 9e0894fb4d..25ee1afad1 100644 --- a/packages/fiber/src/index.tsx +++ b/packages/fiber/src/index.tsx @@ -1,7 +1,13 @@ export * from './three-types' -import * as ReactThreeFiber from './three-types' -export { ReactThreeFiber } -export type { BaseInstance, LocalState } from './core/renderer' +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 667a0596e5..851eb278dd 100644 --- a/packages/fiber/src/native.tsx +++ b/packages/fiber/src/native.tsx @@ -1,7 +1,13 @@ export * from './three-types' -import * as ReactThreeFiber from './three-types' -export { ReactThreeFiber } -export type { BaseInstance, LocalState } from './core/renderer' +export type { + AttachFnType, + AttachType, + ConstructorRepresentation, + Catalogue, + Args, + InstanceProps, + Instance, +} from './core/renderer' 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 index ec393b4920..e5cfa5a9ae 100644 --- a/packages/fiber/src/three-types.ts +++ b/packages/fiber/src/three-types.ts @@ -1,399 +1,57 @@ -import * as THREE from 'three' -import { EventHandlers } from './core/events' -import { AttachType } from './core/renderer' +import type * as THREE from 'three' +import type { EventHandlers } from './core/events' +import type { InstanceProps, ConstructorRepresentation } from './core/renderer' -export type NonFunctionKeys = { [K in keyof T]-?: T[K] extends Function ? never : K }[keyof T] -export 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 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 +interface MathRepresentation { + set(...args: any[]): any +} +interface VectorRepresentation extends MathRepresentation { + setScalar(s: number): any +} +type MathProps

= { + [K in keyof P]: P[K] extends infer M + ? M extends THREE.Color + ? ConstructorParameters | THREE.ColorRepresentation + : M extends MathRepresentation + ? M extends VectorRepresentation + ? M | Parameters | Parameters[0] + : M | Parameters + : {} + : {} +} -export type AttachCallback = string | ((child: any, parentInstance: any) => void) +interface RaycastableRepresentation { + raycast(raycaster: THREE.Raycaster, intersects: THREE.Intersection[]): void +} +type EventProps

= P extends RaycastableRepresentation ? Partial : {} -export interface NodeProps { - attach?: AttachType - /** Constructor arguments */ - args?: Args

+interface ReactProps

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

key?: React.Key } -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 +type ElementProps> = Partial< + Overwrite & MathProps

& EventProps

> > -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 ThreeElement = Mutable< + Overwrite, Omit>, 'object'>> > -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 +type ThreeExports = typeof THREE +type ThreeElementsImpl = { + [K in keyof ThreeExports as Uncapitalize]: ThreeExports[K] extends ConstructorRepresentation + ? ThreeElement + : never +} - // 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 +export interface ThreeElements extends ThreeElementsImpl { + primitive: Omit, 'args'> & { object: object } } declare global { diff --git a/packages/fiber/src/web/Canvas.tsx b/packages/fiber/src/web/Canvas.tsx index 5d82f906ab..c88f23903a 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/__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/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) }) }) 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 821b027405..e6aec2d8a1 100644 --- a/packages/fiber/tests/renderer.test.tsx +++ b/packages/fiber/tests/renderer.test.tsx @@ -1,202 +1,96 @@ import * as React from 'react' import * as THREE from 'three' -import { - ReconcilerRoot, - createRoot, - act, - useFrame, - extend, - ReactThreeFiber, - useThree, - createPortal, -} from '../src/index' -import { UseBoundStore } from 'zustand' -import { privateKeys, RootState } from '../src/core/store' +import { ReconcilerRoot, createRoot, act, extend, ThreeElement } from '../src/index' import { suspend } from 'suspend-react' -type ComponentMesh = THREE.Mesh - -interface ObjectWithBackground extends THREE.Object3D { - background: THREE.Color -} +class CustomElement extends THREE.Object3D {} -/* This class is used for one of the tests */ -class HasObject3dMember extends THREE.Object3D { - public attachment?: THREE.Object3D = undefined +declare module '@react-three/fiber' { + interface ThreeElements { + customElement: ThreeElement + } } -/* This class is used for one of the tests */ -class HasObject3dMethods extends THREE.Object3D { - attachedObj3d?: THREE.Object3D - detachedObj3d?: THREE.Object3D +extend({ CustomElement }) - customAttach(obj3d: THREE.Object3D) { - this.attachedObj3d = obj3d - } +type ComponentMesh = THREE.Mesh - detach(obj3d: THREE.Object3D) { - this.detachedObj3d = obj3d - } -} +const expectToThrow = async (callback: () => any) => { + const error = console.error + console.error = jest.fn() -class MyColor extends THREE.Color { - constructor(col: number) { - super(col) + let thrown = false + try { + await callback() + } catch (_) { + thrown = true } -} - -extend({ HasObject3dMember, HasObject3dMethods }) -declare module '@react-three/fiber' { - interface ThreeElements { - hasObject3dMember: ReactThreeFiber.Node - hasObject3dMethods: ReactThreeFiber.Node - myColor: ReactThreeFiber.Node - } + expect(thrown).toBe(true) + expect(console.error).toBeCalled() + console.error = error } -beforeAll(() => { - Object.defineProperty(window, 'devicePixelRatio', { - configurable: true, - value: 2, - }) -}) - describe('renderer', () => { let root: ReconcilerRoot = null! 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 () => { @@ -226,387 +120,277 @@ 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))} /> - , - ), - ) - const { scene } = store.getState() - - // Attach - const object = scene.children[0] as HasObject3dMethods - expect(object.attachedObj3d).toBeInstanceOf(THREE.Mesh) - expect(object.children.length).toBe(0) - - // Detach - expect(object.detachedObj3d).toBeUndefined() - await act(async () => root.render()) - expect(object.detachedObj3d).toBeInstanceOf(THREE.Mesh) - }) - }) + it('should handle attach', async () => { + const lifecycle: string[] = [] - 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) - } + const Test = () => { + return ( + + + + + void lifecycle.push('mount')} + attach={() => (lifecycle.push('attach'), () => lifecycle.push('detach'))} + /> + + ) } - - await act(async () => root.render()) - await act(async () => root.unmount()) - - expect(log).toEqual(['render Foo', 'mount Foo', 'unmount Foo']) - }) - - 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 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.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) - expect((scene.children[0] as any).test).toBeInstanceOf(THREE.Group) + // attaches before presenting + expect(lifecycle).toStrictEqual(['attach', 'mount']) }) - 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(2) - 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 update props reactively', async () => { + const store = await act(async () => root.render()) + const { scene } = store.getState() + const group = scene.children[0] as THREE.Group - it('should respect frameloop="never" in xr', async () => { - let respected = true + // Initial + expect(group.name).toBe(new THREE.Group().name) - const Test = () => useFrame(() => (respected = false)) + // Set + await act(async () => root.render()) + expect(group.name).toBe('one') - await act(async () => { - const state = root - .configure({ frameloop: 'never' }) - .render() - .getState() - state.gl.xr.isPresenting = true - state.gl.xr.dispatchEvent({ type: 'sessionstart' }) - }) + // Update + await act(async () => root.render()) + expect(group.name).toBe('two') - expect(respected).toEqual(true) + // Unset + await act(async () => root.render()) + expect(group.name).toBe(new THREE.Group().name) }) - it('will render components that are extended', async () => { - extend({ MyColor }) + 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' - const store = await act(async () => root.render()) - const { scene } = store.getState() + // Initial + expect(internal.interaction.length).toBe(0) - const { myColor } = scene as THREE.Scene & { myColor: MyColor } - expect(myColor).toBeInstanceOf(MyColor) - expect(myColor.toArray()).toStrictEqual([0, 0, 1]) - }) + // Set + await act(async () => root.render( void 0} />)) + expect(internal.interaction.length).toBe(1) + expect(internal.interaction).toStrictEqual([mesh]) - it('should set renderer props via gl prop', async () => { - const store = await act(async () => root.configure({ gl: { physicallyCorrectLights: true } }).render()) - const { gl } = store.getState() + // Update + await act(async () => root.render( void 0} />)) + expect(internal.interaction.length).toBe(1) + expect(internal.interaction).toStrictEqual([mesh]) - expect(gl.physicallyCorrectLights).toBe(true) + // Unset + await act(async () => root.render()) + expect(internal.interaction.length).toBe(0) }) - 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() + it('should handle the args prop reactively', async () => { + const ref = React.createRef() + const child = React.createRef() + const attachedChild = React.createRef() - 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 Test = (props: JSX.IntrinsicElements['mesh']) => ( + + + + ) - 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) - }) + // 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()), + ) - it('should respect legacy prop', async () => { - await act(async () => root.configure({ legacy: true }).render()) - expect((THREE as any).ColorManagement.legacyMode).toBe(true) + // 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) - await act(async () => root.configure({ legacy: false }).render()) - expect((THREE as any).ColorManagement.legacyMode).toBe(false) + // 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('can handle createPortal', async () => { - const scene = new THREE.Scene() + it('should handle the object prop reactively', async () => { + const ref = React.createRef() + const child = React.createRef() + const attachedChild = React.createRef() - let state: RootState = null! - let portalState: RootState = null! - - const Normal = () => { - const three = useThree() - state = three - - return - } - - const Portal = () => { - const three = useThree() - portalState = three + const Test = (props: JSX.IntrinsicElements['primitive']) => ( + + + + + ) - return - } + const object1 = new THREE.Object3D() + const child1 = new THREE.Object3D() + object1.add(child1) - await act(async () => { - root.render( - <> - - {createPortal(, scene, { scene })} - , - ) - }) + const object2 = new THREE.Object3D() + const child2 = new THREE.Object3D() + object2.add(child2) - // Renders into portal target - expect(scene.children.length).not.toBe(0) + // Initial + await act(async () => root.render()) + expect(ref.current).toBe(object1) + expect(ref.current!.children).toStrictEqual([child1, child.current]) + expect(ref.current!.userData.attach).toBe(attachedChild.current) - // Creates an isolated state enclave - expect(state.scene).not.toBe(scene) - expect(portalState.scene).toBe(scene) + // Throw on undefined + await expectToThrow( + // @ts-expect-error + async () => await act(async () => root.render()), + ) - // 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) + // Update + await act(async () => root.render()) + expect(ref.current).toBe(object2) + 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).toStrictEqual([child1, child.current]) + expect(ref.current!.userData.attach).toBe(attachedChild.current) }) - 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()) + 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 objectExternal = Object.assign(new THREE.Object3D(), { dispose: jest.fn() }) + object.add(objectExternal) + + const disposeDeclarativePrimitive = jest.fn() + + const Test = (props: JSX.IntrinsicElements['mesh']) => ( + { + if (!self) return + self.dispose = dispose + }} + onClick={() => void 0}> + { + if (!self) return + self.dispose = childDispose + }} + /> + { + if (!self) return + self.dispose = attachDispose + }} + attach={() => (attach(), detach)} + /> + { + if (!self) return + self.dispose = flagDispose + }} + /> + + { + if (!self) return + self.dispose = disposeDeclarativePrimitive + }} + /> + + + ) - expect(groupHandle).toBeDefined() - const prevUUID = groupHandle!.uuid + const store = await act(async () => root.render()) + await act(async () => root.render(null)) - await act(async () => root.render()) + const { scene, internal } = store.getState() - expect(groupHandle).toBeDefined() - expect(prevUUID).not.toBe(groupHandle!.uuid) + // 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 + 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() + // Only disposes of declarative primitive children + expect(objectExternal.dispose).not.toBeCalled() + expect(disposeDeclarativePrimitive).toBeCalled() }) it('should gracefully handle text', async () => { @@ -626,4 +410,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={(_, 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) + }) }) diff --git a/packages/fiber/tests/utils.test.ts b/packages/fiber/tests/utils.test.ts index 0e82a123cc..2a10d8744c 100644 --- a/packages/fiber/tests/utils.test.ts +++ b/packages/fiber/tests/utils.test.ts @@ -1,4 +1,28 @@ -import { is } from '../src/core/utils' +import * as THREE from 'three' +import { UseBoundStore } from 'zustand' +import { RootState, Instance } from '../src' +import { + is, + dispose, + REACT_INTERNAL_PROPS, + getInstanceProps, + prepare, + resolve, + attach, + detach, + RESERVED_PROPS, + diffProps, + applyProps, + 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 @@ -97,3 +121,317 @@ describe('is', () => { expect(is.equ([1, 2], [1, 2, 3], { strict: false })).toBe(true) }) }) + +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 instance = prepare(object, storeMock, 'object3D', { name: 'object' }) + + expect(instance.root).toBe(storeMock) + 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, storeMock, 'container', {}) + expect(container.__r3f).toBe(containerDesc) + expect(instance).toBe(containerDesc) + }) +}) + +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('attach / detach', () => { + it('should attach & detach using string values', () => { + const parent = prepare({ prop: null }, storeMock, '', {}) + const child = prepare({}, storeMock, '', { 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({}, storeMock, '', {}) + const child = prepare({}, storeMock, '', { 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 }, storeMock, '', {}) + const child = prepare({}, storeMock, '', { 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 instance = prepare({}, storeMock, '', { foo: true }) + const newProps = { foo: true, bar: false } + + const filtered = diffProps(instance, newProps) + expect(filtered).toStrictEqual({ bar: false }) + }) + + it('should pick removed props for HMR', () => { + const instance = prepare(new THREE.Object3D(), storeMock, '', { position: [0, 0, 1] }) + const newProps = {} + + 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', () => { + const get = jest.fn() + const set = jest.fn() + + const props = { foo: true } + const filtered = diffProps( + prepare({}, storeMock, '', {}), + 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 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, layer: new THREE.Layers() } + + // Same constructor, copy + applyProps(target, { color: new THREE.Color() }) + expect(target.color).toBeInstanceOf(THREE.Color) + expect(color.copy).toHaveBeenCalledTimes(1) + + // 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) + }) + + 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 () => { + // 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', () => { + const target = new THREE.Mesh() + applyProps(target, { 'material-color': 0x000000 }) + + 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() + }) +}) diff --git a/packages/shared/setupTests.ts b/packages/shared/setupTests.ts index 6c1d564107..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 @@ -57,4 +60,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/__tests__/RTTR.core.test.tsx b/packages/test-renderer/src/__tests__/RTTR.core.test.tsx index f8354422d7..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 ( @@ -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..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 @@ -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 { @@ -11,7 +55,6 @@ Array [ Object { "children": Array [], "props": Object { - "args": Array [], "array": Float32Array [ -1, -1, @@ -32,6 +75,7 @@ Array [ -1, 1, ], + "attach": "attributes-position", "count": 6, "itemSize": 3, }, @@ -39,38 +83,24 @@ Array [ }, ], "props": Object { - "args": Array [], + "attach": "geometry", }, "type": "bufferGeometry", }, Object { "children": Array [], "props": Object { - "args": Array [], + "attach": "material", "color": "hotpink", }, "type": "meshBasicMaterial", }, ], - "props": Object { - "args": Array [], - }, + "props": Object {}, "type": "mesh", }, - Object { - "children": Array [], - "props": Object { - "args": Array [ - 0, - 0, - 255, - ], - }, - "type": "color", - }, ], "props": Object { - "args": Array [], "position": Array [ 1, 2, @@ -82,6 +112,64 @@ Array [ ] `; +exports[`ReactThreeTestRenderer Core exposes the instance 1`] = ` +Array [ + Object { + "children": Array [ + Object { + "children": Array [], + "props": Object { + "args": Array [ + 2, + 2, + ], + "attach": "geometry", + }, + "type": "boxGeometry", + }, + Object { + "children": Array [], + "props": Object { + "attach": "material", + }, + "type": "meshBasicMaterial", + }, + ], + "props": Object {}, + "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 { + "attach": "material", + }, + "type": "meshStandardMaterial", + }, + ], + "props": Object {}, + "type": "mesh", + }, +] +`; + exports[`ReactThreeTestRenderer Core toTree() handles complicated tree of fragments 1`] = ` Array [ Object { @@ -94,13 +182,12 @@ Array [ 0, 0, ], + "attach": "background", }, "type": "color", }, ], - "props": Object { - "args": Array [], - }, + "props": Object {}, "type": "group", }, Object { @@ -113,13 +200,12 @@ Array [ 0, 255, ], + "attach": "background", }, "type": "color", }, ], - "props": Object { - "args": Array [], - }, + "props": Object {}, "type": "group", }, Object { @@ -132,13 +218,22 @@ Array [ 0, 0, ], + "attach": "background", }, "type": "color", }, ], - "props": Object { - "args": Array [], - }, + "props": Object {}, + "type": "group", + }, +] +`; + +exports[`ReactThreeTestRenderer Core toTree() handles nested Fragments 1`] = ` +Array [ + Object { + "children": Array [], + "props": Object {}, "type": "group", }, ] @@ -157,19 +252,19 @@ Array [ 2, 2, ], + "attach": "geometry", }, "type": "boxGeometry", }, Object { "children": Array [], "props": Object { - "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], "position-z": 12, }, "type": "mesh", @@ -183,19 +278,19 @@ Array [ 4, 4, ], + "attach": "geometry", }, "type": "boxGeometry", }, Object { "children": Array [], "props": Object { - "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], "position-y": 12, }, "type": "mesh", @@ -209,27 +304,25 @@ Array [ 6, 6, ], + "attach": "geometry", }, "type": "boxGeometry", }, Object { "children": Array [], "props": Object { - "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], "position-x": 12, }, "type": "mesh", }, ], - "props": Object { - "args": Array [], - }, + "props": Object {}, "type": "group", }, ] @@ -248,19 +341,19 @@ Array [ 6, 6, ], + "attach": "geometry", }, "type": "boxGeometry", }, Object { "children": Array [], "props": Object { - "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], "rotation-x": 1, }, "type": "mesh", @@ -274,19 +367,19 @@ Array [ 4, 4, ], + "attach": "geometry", }, "type": "boxGeometry", }, Object { "children": Array [], "props": Object { - "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], "position-y": 12, }, "type": "mesh", @@ -300,27 +393,25 @@ Array [ 2, 2, ], + "attach": "geometry", }, "type": "boxGeometry", }, Object { "children": Array [], "props": Object { - "args": Array [], + "attach": "material", }, "type": "meshBasicMaterial", }, ], "props": Object { - "args": Array [], "position-x": 12, }, "type": "mesh", }, ], - "props": Object { - "args": Array [], - }, + "props": Object {}, "type": "group", }, ] 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 69a4b3b28c..d47cdf6d09 100644 --- a/packages/test-renderer/src/createTestInstance.ts +++ b/packages/test-renderer/src/createTestInstance.ts @@ -1,30 +1,35 @@ -import { Object3D } from 'three' +import type * as THREE from 'three' +import type { Instance } from '@react-three/fiber' -import type { MockInstance, MockScene, 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 | MockScene) { - this._fiber = fiber as MockInstance + constructor(fiber: Instance) { + this._fiber = fiber } - public get instance(): Object3D { - return this._fiber as unknown as TInstance + public get fiber(): Instance { + 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) } @@ -40,47 +45,35 @@ export class ReactThreeTestInstance { } private getChildren = ( - fiber: MockInstance, + fiber: Instance, 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)) - } - } - - public find = (decider: (node: ReactThreeTestInstance) => boolean): ReactThreeTestInstance => - expectOne(findAll(this, decider), `matching custom checker: ${decider.toString()}`) + ): ReactThreeTestInstance[] => + 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 | MockScene): 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 0266dbaa11..36359ebec5 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 { Instance } from '@react-three/fiber' 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: 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 d69f20f2f8..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 { MockSceneChild, MockScene } 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: Instance[]): 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: Instance): Tree => toTreeBranch(root.children) diff --git a/packages/test-renderer/src/index.tsx b/packages/test-renderer/src/index.tsx index 97a95b0082..97d329e155 100644 --- a/packages/test-renderer/src/index.tsx +++ b/packages/test-renderer/src/index.tsx @@ -1,22 +1,20 @@ import * as React from 'react' import * as THREE from 'three' -import { extend, _roots as mockRoots, createRoot, reconciler, act } from '@react-three/fiber' +import { extend, _roots as mockRoots, createRoot, reconciler, act, Instance } from '@react-three/fiber' 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 { MockScene } from './types/internal' import type { CreateOptions, Renderer } from './types/public' import { wrapFiber } from './createTestInstance' // Extend catalogue for render API in tests. -extend(THREE) +extend(THREE as any) type X = | ((contextId: 'webgl', options?: WebGLContextAttributes) => WebGLRenderingContext | null) @@ -44,67 +42,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 Instance['object']).__r3f! 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 @@ -112,7 +88,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 d4b54d936b..ab1a89a13a 100644 --- a/packages/test-renderer/src/types/internal.ts +++ b/packages/test-renderer/src/types/internal.ts @@ -1,26 +1,3 @@ -import * as THREE from 'three' -import { UseBoundStore } from 'zustand' - -import type { BaseInstance, LocalState, 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 type CreateCanvasParameters = { beforeReturn?: (canvas: HTMLCanvasElement) => void width?: number