Skip to content

Commit 31d3265

Browse files
authored
feat(RTTR): handle primitives in test-renderer and fix queries in TestInstances (#3507)
1 parent 7875aef commit 31d3265

File tree

7 files changed

+226
-10
lines changed

7 files changed

+226
-10
lines changed

jest.config.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ module.exports = {
1010
'<rootDir>/packages/fiber/dist',
1111
'<rootDir>/packages/fiber/src/index',
1212
'<rootDir>/packages/test-renderer/dist',
13-
'<rootDir>/test-utils',
1413
],
1514
coverageDirectory: './coverage/',
1615
collectCoverage: false,

packages/test-renderer/markdown/rttr.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Similar to the [`act()` in `react-test-renderer`](https://reactjs.org/docs/test-
125125
#### Act example (using jest)
126126
127127
```tsx
128-
import ReactThreeTestRenderer from 'react-three-test-renderer'
128+
import ReactThreeTestRenderer from '@react-three/test-renderer'
129129

130130
const Mesh = () => {
131131
const meshRef = React.useRef()

packages/test-renderer/src/__tests__/RTTR.core.test.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,10 +351,100 @@ describe('ReactThreeTestRenderer Core', () => {
351351
expect(renderer.toTree()).toMatchSnapshot()
352352
})
353353

354+
it('correctly searches through multiple levels in regular objects', async () => {
355+
// Create a deep tree: group -> mesh -> mesh -> mesh
356+
const renderer = await ReactThreeTestRenderer.create(
357+
<group name="root-group">
358+
<mesh name="level1-mesh">
359+
<boxGeometry />
360+
<meshBasicMaterial color="red" />
361+
<mesh name="level2-mesh">
362+
<boxGeometry />
363+
<meshBasicMaterial color="green" />
364+
<mesh name="level3-mesh">
365+
<boxGeometry />
366+
<meshBasicMaterial color="blue" />
367+
</mesh>
368+
</mesh>
369+
</mesh>
370+
</group>,
371+
)
372+
373+
// Test from the root
374+
const allMeshes = renderer.scene.findAllByType('Mesh')
375+
expect(allMeshes.length).toBe(3) // Should find all three meshes
376+
377+
// Test from an intermediate node
378+
const topMesh = renderer.scene.find((node) => node.props.name === 'level1-mesh')
379+
const nestedMeshes = topMesh.findAllByType('Mesh')
380+
expect(nestedMeshes.length).toBe(2) // Should find the two nested meshes
381+
382+
// Find a deeply nested mesh from an intermediate node by property
383+
const level3 = topMesh.find((node) => node.props.name === 'level3-mesh')
384+
expect(level3).toBeDefined()
385+
expect(level3.type).toBe('Mesh')
386+
})
387+
388+
it('Can search from retrieved primitive Instance', async () => {
389+
const group = new THREE.Group()
390+
group.name = 'PrimitiveGroup'
391+
392+
const childMesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 'red' }))
393+
childMesh.name = 'PrimitiveChildMesh'
394+
group.add(childMesh)
395+
396+
const nestedMesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 'red' }))
397+
nestedMesh.name = 'PrimitiveNestedChildMesh'
398+
childMesh.add(nestedMesh)
399+
400+
const renderer = await ReactThreeTestRenderer.create(<primitive object={group} />)
401+
402+
const foundGroup = renderer.scene.findByType('Group')
403+
const foundMesh = foundGroup.children[0]
404+
const foundNestedMesh = foundMesh.findByType('Mesh')
405+
expect(foundNestedMesh).toBeDefined()
406+
})
407+
354408
it('root instance and refs return the same value', async () => {
355409
let refInst = null
356410
const renderer = await ReactThreeTestRenderer.create(<mesh ref={(ref) => (refInst = ref)} />)
357411
const root = renderer.getInstance() // this will be Mesh
358412
expect(root).toEqual(refInst)
359413
})
414+
415+
it('handles primitive objects and their children correctly in toGraph', async () => {
416+
// Create a component with both regular objects and primitives with children
417+
const PrimitiveTestComponent = () => {
418+
// Create a THREE.js group with mesh children
419+
const group = new THREE.Group()
420+
group.name = 'PrimitiveGroup'
421+
422+
const childMesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshBasicMaterial({ color: 'red' }))
423+
childMesh.name = 'PrimitiveChildMesh'
424+
group.add(childMesh)
425+
426+
// Add a nested group to test deeper hierarchies
427+
const nestedGroup = new THREE.Group()
428+
nestedGroup.name = 'NestedGroup'
429+
const nestedMesh = new THREE.Mesh(new THREE.SphereGeometry(0.5), new THREE.MeshBasicMaterial({ color: 'blue' }))
430+
nestedMesh.name = 'NestedMesh'
431+
nestedGroup.add(nestedMesh)
432+
group.add(nestedGroup)
433+
434+
return (
435+
<>
436+
<mesh name="RegularMesh">
437+
<boxGeometry args={[2, 2]} />
438+
<meshBasicMaterial />
439+
</mesh>
440+
441+
<primitive object={group} />
442+
</>
443+
)
444+
}
445+
446+
const renderer = await ReactThreeTestRenderer.create(<PrimitiveTestComponent />)
447+
448+
expect(renderer.toGraph()).toMatchSnapshot()
449+
})
360450
})

packages/test-renderer/src/__tests__/__snapshots__/RTTR.core.test.tsx.snap

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,49 @@ Array [
171171
]
172172
`;
173173

174+
exports[`ReactThreeTestRenderer Core handles primitive objects and their children correctly in toGraph 1`] = `
175+
Array [
176+
Object {
177+
"children": Array [
178+
Object {
179+
"children": Array [],
180+
"name": "",
181+
"type": "BoxGeometry",
182+
},
183+
Object {
184+
"children": Array [],
185+
"name": "",
186+
"type": "MeshBasicMaterial",
187+
},
188+
],
189+
"name": "RegularMesh",
190+
"type": "Mesh",
191+
},
192+
Object {
193+
"children": Array [
194+
Object {
195+
"children": Array [],
196+
"name": "PrimitiveChildMesh",
197+
"type": "Mesh",
198+
},
199+
Object {
200+
"children": Array [
201+
Object {
202+
"children": Array [],
203+
"name": "NestedMesh",
204+
"type": "Mesh",
205+
},
206+
],
207+
"name": "NestedGroup",
208+
"type": "Group",
209+
},
210+
],
211+
"name": "PrimitiveGroup",
212+
"type": "Group",
213+
},
214+
]
215+
`;
216+
174217
exports[`ReactThreeTestRenderer Core toTree() handles complicated tree of fragments 1`] = `
175218
Array [
176219
Object {

packages/test-renderer/src/createTestInstance.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ import type { Obj, TestInstanceChildOpts } from './types/internal'
55

66
import { expectOne, matchProps, findAll } from './helpers/testInstance'
77

8+
// Helper to create a minimal wrapper for THREE.Object3D children of primitives
9+
const createVirtualInstance = (object: THREE.Object3D, parent: Instance<any>): Instance<THREE.Object3D> => {
10+
// Create the virtual instance for this object
11+
// we can't import the prepare method from packages/fiber/src/core/utils.tsx so we do what we can
12+
const instance: Instance<THREE.Object3D> = {
13+
root: parent.root,
14+
type: object.type.toLowerCase(), // Convert to lowercase to match R3F convention
15+
parent,
16+
children: [],
17+
props: { object },
18+
object,
19+
eventCount: 0,
20+
handlers: {},
21+
isHidden: false,
22+
}
23+
24+
// Recursively process children if they exist
25+
if (object.children && object.children.length > 0) {
26+
const objectChildren = object.children as THREE.Object3D[]
27+
instance.children = Array.from(objectChildren).map((child) => createVirtualInstance(child, instance))
28+
}
29+
30+
return instance
31+
}
32+
833
export class ReactThreeTestInstance<TObject extends THREE.Object3D = THREE.Object3D> {
934
_fiber: Instance<TObject>
1035

@@ -47,11 +72,30 @@ export class ReactThreeTestInstance<TObject extends THREE.Object3D = THREE.Objec
4772
private getChildren = (
4873
fiber: Instance,
4974
opts: TestInstanceChildOpts = { exhaustive: false },
50-
): ReactThreeTestInstance[] =>
51-
fiber.children.filter((child) => !child.props.attach || opts.exhaustive).map((fib) => wrapFiber(fib as Instance))
75+
): ReactThreeTestInstance[] => {
76+
// Get standard R3F children
77+
const r3fChildren = fiber.children
78+
.filter((child) => !child.props.attach || opts.exhaustive)
79+
.map((fib) => wrapFiber(fib as Instance))
80+
81+
// For primitives, also add THREE.js object children
82+
if (fiber.type === 'primitive' && fiber.object.children?.length) {
83+
const threeChildren = fiber.object.children.map((child: THREE.Object3D) => {
84+
// Create a virtual instance that wraps the THREE.js child
85+
const virtualInstance = createVirtualInstance(child, fiber)
86+
return wrapFiber(virtualInstance)
87+
})
88+
89+
r3fChildren.push(...threeChildren)
90+
91+
return r3fChildren
92+
}
93+
94+
return r3fChildren
95+
}
5296

5397
public findAll = (decider: (node: ReactThreeTestInstance) => boolean): ReactThreeTestInstance[] =>
54-
findAll(this as unknown as ReactThreeTestInstance, decider)
98+
findAll(this as unknown as ReactThreeTestInstance, decider, { includeRoot: false })
5599

56100
public find = (decider: (node: ReactThreeTestInstance) => boolean): ReactThreeTestInstance =>
57101
expectOne(this.findAll(decider), `matching custom checker: ${decider.toString()}`)

packages/test-renderer/src/helpers/graph.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Instance } from '@react-three/fiber'
22
import type { SceneGraphItem } from '../types/public'
3+
import type * as THREE from 'three'
34

45
const graphObjectFactory = (
56
type: SceneGraphItem['type'],
@@ -11,5 +12,28 @@ const graphObjectFactory = (
1112
children,
1213
})
1314

14-
export const toGraph = (object: Instance): SceneGraphItem[] =>
15-
object.children.map((child) => graphObjectFactory(child.object.type, child.object.name ?? '', toGraph(child)))
15+
// Helper function to process raw THREE.js children objects
16+
function processThreeChildren(children: THREE.Object3D[]): SceneGraphItem[] {
17+
return children.map((object) =>
18+
graphObjectFactory(
19+
object.type,
20+
object.name || '',
21+
object.children && object.children.length > 0 ? processThreeChildren(object.children) : [],
22+
),
23+
)
24+
}
25+
26+
export const toGraph = (object: Instance): SceneGraphItem[] => {
27+
return object.children.map((child) => {
28+
// Process standard R3F children
29+
const children = toGraph(child)
30+
31+
// For primitives, also include THREE.js object children
32+
if (child.type === 'primitive' && child.object.children?.length) {
33+
const threeChildren = processThreeChildren(child.object.children)
34+
children.push(...threeChildren)
35+
}
36+
37+
return graphObjectFactory(child.object.type, child.object.name ?? '', children)
38+
})
39+
}

packages/test-renderer/src/helpers/testInstance.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,31 @@ export const matchProps = (props: Obj, filter: Obj) => {
2828
return true
2929
}
3030

31-
export const findAll = (root: ReactThreeTestInstance, decider: (node: ReactThreeTestInstance) => boolean) => {
31+
interface FindAllOptions {
32+
/**
33+
* Whether to include the root node in search results.
34+
* When false, only searches within children.
35+
* @default true
36+
*/
37+
includeRoot?: boolean
38+
}
39+
40+
export const findAll = (
41+
root: ReactThreeTestInstance,
42+
decider: (node: ReactThreeTestInstance) => boolean,
43+
options: FindAllOptions = { includeRoot: true },
44+
) => {
3245
const results = []
3346

34-
if (decider(root)) {
47+
// Only include the root node if the option is enabled
48+
if (options.includeRoot !== false && decider(root)) {
3549
results.push(root)
3650
}
3751

52+
// Always search through children
3853
root.allChildren.forEach((child) => {
39-
results.push(...findAll(child, decider))
54+
// When recursively searching children, we always want to include their roots
55+
results.push(...findAll(child, decider, { includeRoot: true }))
4056
})
4157

4258
return results

0 commit comments

Comments
 (0)