Skip to content

Commit

Permalink
Merge pull request #2607 from pmndrs/experiment/object-constructor-ef…
Browse files Browse the repository at this point in the history
…fects

[v9] experiment: create objects with container effects
  • Loading branch information
krispya authored Jun 22, 2023
2 parents dde49a5 + a83e06e commit 66b7cb3
Show file tree
Hide file tree
Showing 3 changed files with 72 additions and 52 deletions.
68 changes: 32 additions & 36 deletions packages/fiber/src/core/reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,37 +82,51 @@ function createInstance(type: string, props: HostConfig['props'], root: RootStor
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'
}

// Set initial props
applyProps(instance.object, props)
const instance = prepare(props.object, root, type, props)

return instance
}

// https://github.com/facebook/react/issues/20271
// This will make sure events and attach are only handled once when trees are complete
function handleContainerEffects(parent: Instance, child: Instance) {
function handleContainerEffects(parent: Instance, child: Instance, beforeChild?: Instance, replace: boolean = false) {
// 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 && isObject3D(child.object)) {
state.internal.interaction.push(child.object)
// Create & link object on first run
if (!child.object) {
// Get target from catalogue
const name = `${child.type[0].toUpperCase()}${child.type.slice(1)}`
const target = catalogue[name]

// Create object
child.object = child.props.object ?? new target(...(child.props.args ?? []))
child.object.__r3f = child

// Set initial props
applyProps(child.object, child.props)
}

// Append instance
if (child.props.attach) {
attach(parent, child)
} else if (child.object instanceof THREE.Object3D && parent.object instanceof THREE.Object3D) {
if (beforeChild) {
child.object.parent = parent.object
parent.object.children.splice(parent.object.children.indexOf(beforeChild.object), replace ? 1 : 0, child.object)
child.object.dispatchEvent({ type: 'added' })
} else {
parent.object.add(child.object)
}
}

// Handle attach
if (child.props.attach) attach(parent, child)
// Link subtree
for (const childInstance of child.children) handleContainerEffects(child, childInstance)

// Tree was updated, request a frame
invalidateInstance(child)
}

function appendChild(parent: HostConfig['instance'], child: HostConfig['instance'] | HostConfig['textInstance']) {
Expand All @@ -122,16 +136,8 @@ function appendChild(parent: HostConfig['instance'], child: HostConfig['instance
child.parent = parent
parent.children.push(child)

// Add Object3Ds if able
if (!child.props.attach && isObject3D(parent.object) && isObject3D(child.object)) {
parent.object.add(child.object)
}

// Attach tree once complete
handleContainerEffects(parent, child)

// Tree was updated, request a frame
invalidateInstance(child)
}

function insertBefore(
Expand All @@ -148,18 +154,8 @@ function insertBefore(
if (childIndex !== -1) parent.children.splice(childIndex, replace ? 1 : 0, child)
if (replace) beforeChild.parent = null

// Manually splice Object3Ds
if (!child.props.attach && isObject3D(parent.object) && isObject3D(child.object) && isObject3D(beforeChild.object)) {
child.object.parent = parent.object
parent.object.children.splice(parent.object.children.indexOf(beforeChild.object), replace ? 1 : 0, child.object)
child.object.dispatchEvent({ type: 'added' })
}

// Attach tree once complete
handleContainerEffects(parent, child)

// Tree was updated, request a frame
invalidateInstance(child)
handleContainerEffects(parent, child, beforeChild, replace)
}

function removeChild(
Expand Down
13 changes: 11 additions & 2 deletions packages/fiber/src/core/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ export function prepare<T = any>(target: T, root: RootStore, type: string, props
const object = target as unknown as Instance['object']

// Create instance descriptor
let instance = object.__r3f
let instance = object?.__r3f
if (!instance) {
instance = {
root,
Expand All @@ -238,7 +238,10 @@ export function prepare<T = any>(target: T, root: RootStore, type: string, props
handlers: {},
isHidden: false,
}
object.__r3f = instance
if (object) {
object.__r3f = instance
if (type) applyProps(object, instance.props)
}
}

return instance
Expand Down Expand Up @@ -463,6 +466,12 @@ export function applyProps<T = any>(object: Instance<T>['object'], props: Instan
}
}

// Auto-attach geometries and materials
if (instance && 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'
}

if (instance) invalidateInstance(instance)

return object
Expand Down
43 changes: 29 additions & 14 deletions packages/fiber/tests/renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import * as THREE from 'three'
import { ReconcilerRoot, createRoot, act, extend, ThreeElement } from '../src/index'
import { suspend } from 'suspend-react'

class CustomElement extends THREE.Object3D {}
class Mock extends THREE.Group {
static instances: string[]
constructor(name: string = '') {
super()
this.name = name
Mock.instances.push(name)
}
}

declare module '@react-three/fiber' {
interface ThreeElements {
customElement: ThreeElement<typeof CustomElement>
mock: ThreeElement<typeof Mock>
}
}

extend({ CustomElement })
extend({ Mock })

type ComponentMesh = THREE.Mesh<THREE.BoxBufferGeometry, THREE.MeshBasicMaterial>

Expand All @@ -34,7 +41,10 @@ const expectToThrow = async (callback: () => any) => {
describe('renderer', () => {
let root: ReconcilerRoot<HTMLCanvasElement> = null!

beforeEach(() => (root = createRoot(document.createElement('canvas'))))
beforeEach(() => {
root = createRoot(document.createElement('canvas'))
Mock.instances = []
})
afterEach(async () => act(async () => root.unmount()))

it('should render empty JSX', async () => {
Expand All @@ -45,29 +55,32 @@ describe('renderer', () => {
})

it('should render native elements', async () => {
const store = await act(async () => root.render(<group />))
const store = await act(async () => root.render(<group name="native" />))
const { scene } = store.getState()

expect(scene.children.length).toBe(1)
expect(scene.children[0]).toBeInstanceOf(THREE.Group)
expect(scene.children[0].name).toBe('native')
})

it('should render extended elements', async () => {
const store = await act(async () => root.render(<customElement />))
const store = await act(async () => root.render(<mock name="mock" />))
const { scene } = store.getState()

expect(scene.children.length).toBe(1)
expect(scene.children[0]).toBeInstanceOf(CustomElement)
expect(scene.children[0]).toBeInstanceOf(Mock)
expect(scene.children[0].name).toBe('mock')
})

it('should render primitives', async () => {
const object = new THREE.Object3D()

const store = await act(async () => root.render(<primitive object={object} />))
const store = await act(async () => root.render(<primitive name="primitive" object={object} />))
const { scene } = store.getState()

expect(scene.children.length).toBe(1)
expect(scene.children[0]).toBe(object)
expect(object.name).toBe('primitive')
})

it('should go through lifecycle', async () => {
Expand Down Expand Up @@ -449,27 +462,27 @@ describe('renderer', () => {
suspend(async (reconstruct) => reconstruct, [reconstruct])

return (
<group key={reconstruct ? 0 : 1} name="parent">
<group
name="child"
<mock key={reconstruct ? 0 : 1} args={['parent']}>
<mock
args={['child']}
ref={(self) => void (lastMounted = self?.uuid)}
attach={(_, self) => {
calls.push('attach')
lastAttached = self.uuid
return () => calls.push('detach')
}}
/>
</group>
</mock>
)
}

function Test(props: { reconstruct?: boolean }) {
React.useLayoutEffect(() => void calls.push('useLayoutEffect'), [])

return (
<group name="suspense">
<mock args={['suspense']}>
<SuspenseComponent {...props} />
</group>
</mock>
)
}

Expand All @@ -478,10 +491,12 @@ describe('renderer', () => {
// Should complete tree before layout-effects fire
expect(calls).toStrictEqual(['attach', 'useLayoutEffect'])
expect(lastAttached).toBe(lastMounted)
expect(Mock.instances).toStrictEqual(['suspense', 'parent', 'child'])

await act(async () => root.render(<Test reconstruct />))

expect(calls).toStrictEqual(['attach', 'useLayoutEffect', 'detach', 'attach'])
expect(lastAttached).toBe(lastMounted)
expect(Mock.instances).toStrictEqual(['suspense', 'parent', 'child', 'parent', 'child'])
})
})

0 comments on commit 66b7cb3

Please sign in to comment.