Skip to content

Commit 9cd75c2

Browse files
authored
Merge pull request #391 from pmndrs/feat/select-exclude
feat(Select): add exclude prop
2 parents 6ce42a4 + e191bb6 commit 9cd75c2

3 files changed

Lines changed: 236 additions & 16 deletions

File tree

‎docs/selection.mdx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ when things have to be grouped. These two components take care of it:
1717
<Select
1818
children: JSX.Element | JSX.Element[]
1919
enabled?: boolean
20+
/** Keeps this subtree out of the selection, even when an ancestor Select is enabled. */
21+
exclude?: boolean
2022
>
2123
```
2224

@@ -36,7 +38,7 @@ instance `Outline`) will acknowledge it.
3638
```
3739

3840
Selection can be nested and group multiple object, higher up selection take
39-
precence over lower ones. The following for instance will select everything.
41+
precedence over lower ones. The following for instance will select everything.
4042
Remove the outmost `enabled` and only the two mesh group is selected. You can
4143
flip the selections or bind them to interactions and state.
4244

@@ -51,3 +53,16 @@ flip the selections or bind them to interactions and state.
5153
</Select>
5254
</Select>
5355
```
56+
57+
To keep part of an enabled subtree out of the selection, wrap it in a `Select`
58+
with `exclude`. Selects nested inside an excluded subtree can still select
59+
their own objects.
60+
61+
```jsx
62+
<Select enabled>
63+
<mesh /> {/* selected */}
64+
<Select exclude>
65+
<mesh /> {/* not selected */}
66+
</Select>
67+
</Select>
68+
```

‎src/Selection.tsx‎

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { type ThreeElements } from '@react-three/fiber'
22
import {
33
createContext,
44
use,
5+
useCallback,
56
useEffect,
7+
useLayoutEffect,
68
useMemo,
9+
useReducer,
710
useRef,
811
useState,
912
type Dispatch,
@@ -19,10 +22,23 @@ export type Api = {
1922
}
2023
export type SelectApi = Omit<ThreeElements['group'], 'ref'> & {
2124
enabled?: boolean
25+
/** Keeps this subtree out of the selection, even when an ancestor Select is enabled. */
26+
exclude?: boolean
2227
}
2328

2429
export const selectionContext = /* @__PURE__ */ createContext<Api | null>(null)
2530

31+
// Lets a nested Select ask its ancestors to re-traverse (e.g. when `exclude` toggles).
32+
const refreshContext = /* @__PURE__ */ createContext<(() => void) | null>(null)
33+
34+
// How many Selects claim each object, so one letting go doesn't drop another's claim.
35+
const claims = new WeakMap<Object3D, number>()
36+
const excluded = new WeakSet<Object3D>()
37+
38+
function updateClaims(objects: Object3D[], delta: number): void {
39+
for (const o of objects) claims.set(o, (claims.get(o) ?? 0) + delta)
40+
}
41+
2642
export function Selection({ children, enabled = true }: { enabled?: boolean; children: ReactNode }) {
2743
const [selected, select] = useState<Object3D[]>([])
2844
const value = useMemo(() => ({ selected, select, enabled }), [selected, select, enabled])
@@ -35,52 +51,76 @@ function isSelectable(object: Object3D): boolean {
3551
return !!(o.isMesh || o.isLine || o.isPoints)
3652
}
3753

38-
export function Select({ enabled = false, children, ...props }: SelectApi) {
54+
function collect(object: Object3D, out: Object3D[]): void {
55+
if (excluded.has(object)) return
56+
if (isSelectable(object)) out.push(object)
57+
for (const child of object.children) collect(child, out)
58+
}
59+
60+
export function Select({ enabled = false, exclude = false, children, ...props }: SelectApi) {
3961
const group = useRef<Group>(null!)
4062
// Stable, unlike the context value - avoids retriggering off our own write.
4163
const select = use(selectionContext)?.select
64+
const refreshParent = use(refreshContext)
4265
const claimed = useRef<Object3D[]>([])
66+
const [version, bump] = useReducer((v: number) => v + 1, 0)
67+
const refresh = useCallback(() => {
68+
bump()
69+
refreshParent?.()
70+
}, [refreshParent])
71+
72+
useLayoutEffect(() => {
73+
if (!exclude) return
74+
const g = group.current
75+
excluded.add(g)
76+
refreshParent?.()
77+
return () => {
78+
excluded.delete(g)
79+
refreshParent?.()
80+
}
81+
}, [exclude, refreshParent])
4382

4483
useEffect(() => {
4584
if (!select) return
4685

4786
const current: Object3D[] = []
48-
if (enabled) {
49-
group.current.traverse((o) => {
50-
if (isSelectable(o)) current.push(o)
51-
})
52-
}
87+
if (enabled && !exclude) for (const child of group.current.children) collect(child, current)
5388

5489
const previouslyClaimed = claimed.current
5590
claimed.current = current
91+
updateClaims(previouslyClaimed, -1)
92+
updateClaims(current, 1)
5693

5794
select((prev) => {
5895
const prevSet = new Set(prev)
59-
const currentSet = new Set(current)
6096
const toAdd = current.filter((o) => !prevSet.has(o))
61-
const toRemove = previouslyClaimed.filter((o) => !currentSet.has(o) && prevSet.has(o))
97+
const toRemove = previouslyClaimed.filter((o) => prevSet.has(o) && !claims.get(o))
6298
if (!toAdd.length && !toRemove.length) return prev
6399
const toRemoveSet = toRemove.length ? new Set(toRemove) : null
64100
const kept = toRemoveSet ? prev.filter((o) => !toRemoveSet.has(o)) : prev
65101
return toAdd.length ? [...kept, ...toAdd] : kept
66102
})
67-
}, [enabled, children, select])
103+
}, [enabled, exclude, children, select, version])
68104

69105
// Separate from the effect above so unmount cleanup doesn't fire on every enabled/children change.
70106
useEffect(() => {
71107
return () => {
72108
if (!select || !claimed.current.length) return
73-
const stillClaimed = new Set(claimed.current)
109+
const released = new Set(claimed.current)
110+
updateClaims(claimed.current, -1)
111+
claimed.current = []
74112
select((prev) => {
75-
const next = prev.filter((o) => !stillClaimed.has(o))
113+
const next = prev.filter((o) => !released.has(o) || claims.get(o))
76114
return next.length !== prev.length ? next : prev
77115
})
78116
}
79117
}, [select])
80118

81119
return (
82-
<group ref={group} {...props}>
83-
{children}
84-
</group>
120+
<refreshContext.Provider value={refresh}>
121+
<group ref={group} {...props}>
122+
{children}
123+
</group>
124+
</refreshContext.Provider>
85125
)
86126
}

‎src/tests/Selection.test.tsx‎

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react'
22
import * as THREE from 'three'
33
import { afterEach, describe, expect, it } from 'vitest'
44
import { Select, Selection, selectionContext } from '../Selection'
5-
import { flush, root } from './test-utils'
5+
import { flush, root, strict } from './test-utils'
66

77
afterEach(async () => {
88
await React.act(async () => {
@@ -321,4 +321,169 @@ describe('Selection/Select', () => {
321321
const afterSecond = updaters[1](afterFirst)
322322
expect(afterSecond).toBe(afterFirst)
323323
})
324+
325+
it('keeps an object selected via the inner Select after the outer one disables', async () => {
326+
const snapshots: THREE.Object3D[][] = []
327+
const meshRef = React.createRef<THREE.Mesh>()
328+
329+
const render = (outerEnabled: boolean) =>
330+
root.render(
331+
<Selection>
332+
<Capture onSnapshot={(s) => snapshots.push(s)} />
333+
<Select enabled={outerEnabled}>
334+
<Select enabled>
335+
<mesh ref={meshRef}>
336+
<boxGeometry />
337+
<meshBasicMaterial />
338+
</mesh>
339+
</Select>
340+
</Select>
341+
</Selection>
342+
)
343+
344+
await React.act(async () => render(true))
345+
await flush()
346+
await flush()
347+
expect(snapshots[snapshots.length - 1]).toContain(meshRef.current)
348+
349+
await React.act(async () => render(false))
350+
await flush()
351+
await flush()
352+
expect(snapshots[snapshots.length - 1]).toContain(meshRef.current)
353+
})
354+
355+
it('keeps claims balanced under StrictMode double-invoked effects', async () => {
356+
const snapshots: THREE.Object3D[][] = []
357+
const meshRef = React.createRef<THREE.Mesh>()
358+
359+
const render = (mounted: boolean, outerEnabled: boolean) =>
360+
root.render(
361+
strict(
362+
<Selection>
363+
<Capture onSnapshot={(s) => snapshots.push(s)} />
364+
{mounted && (
365+
<Select enabled={outerEnabled}>
366+
<Select enabled>
367+
<mesh ref={meshRef} />
368+
</Select>
369+
</Select>
370+
)}
371+
</Selection>
372+
)
373+
)
374+
375+
await React.act(async () => render(true, true))
376+
await flush()
377+
await flush()
378+
const mesh = meshRef.current
379+
expect(snapshots[snapshots.length - 1]).toEqual([mesh])
380+
381+
await React.act(async () => render(true, false))
382+
await flush()
383+
await flush()
384+
expect(snapshots[snapshots.length - 1]).toEqual([mesh])
385+
386+
await React.act(async () => render(false, false))
387+
await flush()
388+
await flush()
389+
expect(snapshots[snapshots.length - 1]).toEqual([])
390+
})
391+
392+
describe('exclude', () => {
393+
it('keeps an excluded subtree out of an enabled ancestor Select (#154)', async () => {
394+
const snapshots: THREE.Object3D[][] = []
395+
const keptRef = React.createRef<THREE.Mesh>()
396+
const excludedRef = React.createRef<THREE.Mesh>()
397+
398+
await React.act(async () =>
399+
root.render(
400+
<Selection>
401+
<Capture onSnapshot={(s) => snapshots.push(s)} />
402+
<Select enabled>
403+
<mesh ref={keptRef} />
404+
<Select exclude>
405+
<group>
406+
<mesh ref={excludedRef} />
407+
</group>
408+
</Select>
409+
</Select>
410+
</Selection>
411+
)
412+
)
413+
await flush()
414+
await flush()
415+
416+
const last = snapshots[snapshots.length - 1]
417+
expect(last).toContain(keptRef.current)
418+
expect(last).not.toContain(excludedRef.current)
419+
})
420+
421+
it('reacts to exclude toggling inside a nested component, without the ancestor re-rendering', async () => {
422+
const snapshots: THREE.Object3D[][] = []
423+
const meshRef = React.createRef<THREE.Mesh>()
424+
let setExclude: (value: boolean) => void = () => {}
425+
426+
function Item() {
427+
const [exclude, set] = React.useState(false)
428+
setExclude = set
429+
return (
430+
<Select exclude={exclude}>
431+
<mesh ref={meshRef} />
432+
</Select>
433+
)
434+
}
435+
436+
await React.act(async () =>
437+
root.render(
438+
<Selection>
439+
<Capture onSnapshot={(s) => snapshots.push(s)} />
440+
<Select enabled>
441+
<Item />
442+
</Select>
443+
</Selection>
444+
)
445+
)
446+
await flush()
447+
await flush()
448+
expect(snapshots[snapshots.length - 1]).toContain(meshRef.current)
449+
450+
await React.act(async () => setExclude(true))
451+
await flush()
452+
await flush()
453+
expect(snapshots[snapshots.length - 1]).not.toContain(meshRef.current)
454+
455+
await React.act(async () => setExclude(false))
456+
await flush()
457+
await flush()
458+
expect(snapshots[snapshots.length - 1]).toContain(meshRef.current)
459+
})
460+
461+
it('still lets an enabled Select inside an excluded subtree select its own objects', async () => {
462+
const snapshots: THREE.Object3D[][] = []
463+
const excludedRef = React.createRef<THREE.Mesh>()
464+
const reselectedRef = React.createRef<THREE.Mesh>()
465+
466+
await React.act(async () =>
467+
root.render(
468+
<Selection>
469+
<Capture onSnapshot={(s) => snapshots.push(s)} />
470+
<Select enabled>
471+
<Select exclude>
472+
<mesh ref={excludedRef} />
473+
<Select enabled>
474+
<mesh ref={reselectedRef} />
475+
</Select>
476+
</Select>
477+
</Select>
478+
</Selection>
479+
)
480+
)
481+
await flush()
482+
await flush()
483+
484+
const last = snapshots[snapshots.length - 1]
485+
expect(last).not.toContain(excludedRef.current)
486+
expect(last).toContain(reselectedRef.current)
487+
})
488+
})
324489
})

0 commit comments

Comments
 (0)