@react-three/postprocessing 3.0.4 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +80 -81
  3. package/dist/EffectComposer.d.ts +14 -6
  4. package/dist/EffectGroup.d.ts +9 -0
  5. package/dist/Selection.d.ts +8 -8
  6. package/dist/createEffectComponent.d.ts +30 -0
  7. package/dist/effects/ASCII.d.ts +29 -7
  8. package/dist/effects/Autofocus.d.ts +8 -7
  9. package/dist/effects/Bloom.d.ts +10 -5
  10. package/dist/effects/BrightnessContrast.d.ts +15 -5
  11. package/dist/effects/ChromaticAberration.d.ts +30 -7
  12. package/dist/effects/ColorAverage.d.ts +7 -8
  13. package/dist/effects/ColorDepth.d.ts +8 -5
  14. package/dist/effects/Depth.d.ts +13 -5
  15. package/dist/effects/DepthOfField.d.ts +7 -18
  16. package/dist/effects/DotScreen.d.ts +15 -5
  17. package/dist/effects/FXAA.d.ts +11 -5
  18. package/dist/effects/Glitch.d.ts +17 -29
  19. package/dist/effects/GodRays.d.ts +7 -19
  20. package/dist/effects/Grid.d.ts +8 -8
  21. package/dist/effects/HueSaturation.d.ts +15 -5
  22. package/dist/effects/LUT.d.ts +4 -3
  23. package/dist/effects/LensFlare.d.ts +52 -11
  24. package/dist/effects/Noise.d.ts +8 -5
  25. package/dist/effects/Outline.d.ts +9 -25
  26. package/dist/effects/Pixelation.d.ts +3 -2
  27. package/dist/effects/Ramp.d.ts +58 -10
  28. package/dist/effects/SMAA.d.ts +10 -5
  29. package/dist/effects/SSAO.d.ts +7 -30
  30. package/dist/effects/ScanlineEffect.d.ts +13 -5
  31. package/dist/effects/SelectiveBloom.d.ts +5 -10
  32. package/dist/effects/Sepia.d.ts +13 -5
  33. package/dist/effects/ShockWave.d.ts +12 -5
  34. package/dist/effects/Texture.d.ts +7 -8
  35. package/dist/effects/TiltShift.d.ts +10 -5
  36. package/dist/effects/TiltShift2.d.ts +44 -8
  37. package/dist/effects/ToneMapping.d.ts +9 -7
  38. package/dist/effects/Vignette.d.ts +19 -5
  39. package/dist/effects/Water.d.ts +17 -5
  40. package/dist/index.d.ts +15 -11
  41. package/dist/index.js +1841 -45
  42. package/dist/index.js.map +1 -1
  43. package/dist/passes/DepthPicking.d.ts +11 -0
  44. package/dist/{effects → passes}/N8AO.d.ts +5 -2
  45. package/dist/util.d.ts +19 -17
  46. package/dist/wrapEffect.d.ts +12 -0
  47. package/package.json +74 -67
  48. package/src/EffectComposer.tsx +379 -200
  49. package/src/EffectGroup.tsx +74 -0
  50. package/src/Selection.tsx +86 -46
  51. package/src/createEffectComponent.tsx +71 -0
  52. package/src/effects/ASCII.tsx +183 -135
  53. package/src/effects/Autofocus.tsx +122 -153
  54. package/src/effects/Bloom.tsx +34 -6
  55. package/src/effects/BrightnessContrast.tsx +7 -4
  56. package/src/effects/ChromaticAberration.tsx +22 -5
  57. package/src/effects/ColorAverage.tsx +4 -15
  58. package/src/effects/ColorDepth.tsx +25 -4
  59. package/src/effects/Depth.tsx +6 -4
  60. package/src/effects/DepthOfField.tsx +109 -89
  61. package/src/effects/DotScreen.tsx +7 -4
  62. package/src/effects/FXAA.tsx +6 -4
  63. package/src/effects/Glitch.tsx +32 -40
  64. package/src/effects/GodRays.tsx +104 -16
  65. package/src/effects/Grid.tsx +27 -21
  66. package/src/effects/HueSaturation.tsx +7 -4
  67. package/src/effects/LUT.tsx +25 -26
  68. package/src/effects/LensFlare.tsx +765 -611
  69. package/src/effects/Noise.tsx +16 -4
  70. package/src/effects/Outline.tsx +84 -117
  71. package/src/effects/Pixelation.tsx +20 -15
  72. package/src/effects/Ramp.tsx +239 -150
  73. package/src/effects/SMAA.tsx +20 -4
  74. package/src/effects/SSAO.tsx +152 -41
  75. package/src/effects/ScanlineEffect.tsx +7 -7
  76. package/src/effects/SelectiveBloom.tsx +98 -126
  77. package/src/effects/Sepia.tsx +6 -4
  78. package/src/effects/ShockWave.tsx +32 -4
  79. package/src/effects/Texture.tsx +28 -23
  80. package/src/effects/TiltShift.tsx +32 -4
  81. package/src/effects/TiltShift2.tsx +156 -90
  82. package/src/effects/ToneMapping.tsx +19 -6
  83. package/src/effects/Vignette.tsx +7 -4
  84. package/src/effects/Water.tsx +50 -35
  85. package/src/index.ts +43 -40
  86. package/src/passes/DepthPicking.tsx +63 -0
  87. package/src/passes/N8AO.tsx +105 -0
  88. package/src/tests/Autofocus.test.tsx +38 -0
  89. package/src/tests/Bloom.test.tsx +76 -0
  90. package/src/tests/ChromaticAberration.test.tsx +81 -0
  91. package/src/tests/ColorDepth.test.tsx +71 -0
  92. package/src/tests/DepthOfField.test.tsx +130 -0
  93. package/src/tests/DepthPicking.test.tsx +250 -0
  94. package/src/tests/EffectComposer.test.tsx +1404 -0
  95. package/src/tests/EffectGroup.test.tsx +337 -0
  96. package/src/tests/Glitch.test.tsx +56 -0
  97. package/src/tests/GodRays.test.tsx +101 -0
  98. package/src/tests/Grid.test.tsx +34 -0
  99. package/src/tests/LUT.test.tsx +64 -0
  100. package/src/tests/N8AO.test.tsx +92 -0
  101. package/src/tests/Outline.test.tsx +211 -0
  102. package/src/tests/SSAO.test.tsx +140 -0
  103. package/src/tests/Selection.test.tsx +324 -0
  104. package/src/tests/SelectiveBloom.test.tsx +166 -0
  105. package/src/tests/ShockWave.test.tsx +95 -0
  106. package/src/tests/TiltShift.test.tsx +76 -0
  107. package/src/tests/createEffectComponent.test.tsx +286 -0
  108. package/src/tests/effects.smoke.test.tsx +238 -0
  109. package/src/tests/test-utils.tsx +96 -0
  110. package/src/tests/wrapEffect.test.tsx +209 -0
  111. package/src/util.tsx +221 -55
  112. package/src/wrapEffect.tsx +123 -0
  113. package/src/EffectComposer.test.tsx +0 -126
  114. package/src/effects/N8AO.tsx +0 -81
@@ -0,0 +1,211 @@
1
+ import { EffectComposer as EffectComposerImpl, OutlineEffect, Selection as PPSelection } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { Mesh, Object3D } from 'three'
4
+ import { afterEach, describe, expect, it, vi } from 'vitest'
5
+ import { EffectComposer } from '../EffectComposer'
6
+ import { Outline } from '../effects/Outline'
7
+ import { Select, Selection } from '../Selection'
8
+ import { flush, root, waitForComposer } from './test-utils'
9
+
10
+ afterEach(async () => {
11
+ await React.act(async () => {
12
+ root.render(null)
13
+ })
14
+ })
15
+
16
+ describe('Outline', () => {
17
+ it('does not re-set its (empty, declarative-mode) selection on unrelated re-renders', async () => {
18
+ const setSpy = vi.spyOn(PPSelection.prototype, 'set')
19
+ const composerRef = React.createRef<EffectComposerImpl>()
20
+
21
+ const render = (tick: number) =>
22
+ root.render(
23
+ <EffectComposer ref={composerRef}>
24
+ <Outline />
25
+ <group name={`tick-${tick}`} />
26
+ </EffectComposer>
27
+ )
28
+
29
+ await React.act(async () => render(0))
30
+ await waitForComposer(composerRef)
31
+ await flush()
32
+ setSpy.mockClear()
33
+
34
+ for (let t = 1; t <= 5; t++) {
35
+ await React.act(async () => render(t))
36
+ await flush()
37
+ }
38
+
39
+ expect(setSpy).not.toHaveBeenCalled()
40
+ setSpy.mockRestore()
41
+ })
42
+
43
+ it('sets its selection from the Selection/Select API and clears it when the object deselects', async () => {
44
+ const effectRef = React.createRef<OutlineEffect>()
45
+ const meshRef = React.createRef<Mesh>()
46
+
47
+ const render = (enabled: boolean) =>
48
+ root.render(
49
+ <EffectComposer>
50
+ <Selection>
51
+ <Select enabled={enabled}>
52
+ <mesh ref={meshRef}>
53
+ <boxGeometry />
54
+ <meshBasicMaterial />
55
+ </mesh>
56
+ </Select>
57
+ <Outline ref={effectRef} />
58
+ </Selection>
59
+ </EffectComposer>
60
+ )
61
+
62
+ await React.act(async () => render(true))
63
+ await flush()
64
+ await flush()
65
+
66
+ expect(Array.from(effectRef.current!.selection)).toContain(meshRef.current)
67
+
68
+ await React.act(async () => render(false))
69
+ await flush()
70
+ await flush()
71
+
72
+ expect(Array.from(effectRef.current!.selection)).not.toContain(meshRef.current)
73
+ })
74
+
75
+ it('does not throw when a selection ref has not attached yet', async () => {
76
+ const composerRef = React.createRef<EffectComposerImpl>()
77
+ const unattachedRef = React.createRef<Object3D>()
78
+
79
+ await React.act(async () =>
80
+ root.render(
81
+ <EffectComposer ref={composerRef}>
82
+ <Outline selection={[unattachedRef]} />
83
+ </EffectComposer>
84
+ )
85
+ )
86
+ await waitForComposer(composerRef)
87
+ await expect(flush()).resolves.not.toThrow()
88
+ })
89
+
90
+ it('applies visibleEdgeColor live, without reconstructing the effect (#143)', async () => {
91
+ const composerRef = React.createRef<EffectComposerImpl>()
92
+ const effectRef = React.createRef<OutlineEffect>()
93
+
94
+ const render = (color: number) =>
95
+ root.render(
96
+ <EffectComposer ref={composerRef}>
97
+ <Outline ref={effectRef} visibleEdgeColor={color} />
98
+ </EffectComposer>
99
+ )
100
+
101
+ await React.act(async () => render(0xff0000))
102
+ await waitForComposer(composerRef)
103
+ await flush()
104
+
105
+ const first = effectRef.current
106
+ expect(first!.visibleEdgeColor.getHex()).toBe(0xff0000)
107
+
108
+ await React.act(async () => render(0x00ff00))
109
+ await flush()
110
+
111
+ expect(effectRef.current).toBe(first)
112
+ expect(effectRef.current!.visibleEdgeColor.getHex()).toBe(0x00ff00)
113
+ })
114
+
115
+ it('accepts a CSS color string for visibleEdgeColor/hiddenEdgeColor, not just a numeric hex (#187, #182)', async () => {
116
+ const composerRef = React.createRef<EffectComposerImpl>()
117
+ const effectRef = React.createRef<OutlineEffect>()
118
+
119
+ await React.act(async () =>
120
+ root.render(
121
+ <EffectComposer ref={composerRef}>
122
+ <Outline ref={effectRef} visibleEdgeColor="red" hiddenEdgeColor="blue" />
123
+ </EffectComposer>
124
+ )
125
+ )
126
+ await waitForComposer(composerRef)
127
+ await flush()
128
+
129
+ expect(effectRef.current!.visibleEdgeColor.getHex()).toBe(0xff0000)
130
+ expect(effectRef.current!.hiddenEdgeColor.getHex()).toBe(0x0000ff)
131
+ })
132
+
133
+ it('resets edgeStrength to its constructor default when the prop is removed', async () => {
134
+ const composerRef = React.createRef<EffectComposerImpl>()
135
+ const effectRef = React.createRef<OutlineEffect>()
136
+
137
+ await React.act(async () =>
138
+ root.render(
139
+ <EffectComposer ref={composerRef}>
140
+ <Outline ref={effectRef} edgeStrength={100} />
141
+ </EffectComposer>
142
+ )
143
+ )
144
+ await waitForComposer(composerRef)
145
+ await flush()
146
+
147
+ expect(effectRef.current!.edgeStrength).toBe(100)
148
+
149
+ await React.act(async () =>
150
+ root.render(
151
+ <EffectComposer ref={composerRef}>
152
+ <Outline ref={effectRef} />
153
+ </EffectComposer>
154
+ )
155
+ )
156
+ await flush()
157
+
158
+ expect(effectRef.current!.edgeStrength).toBe(1)
159
+ })
160
+
161
+ it('still reconstructs when a construction-only prop (resolutionScale) changes', async () => {
162
+ const composerRef = React.createRef<EffectComposerImpl>()
163
+ const effectRef = React.createRef<OutlineEffect>()
164
+
165
+ const render = (resolutionScale: number) =>
166
+ root.render(
167
+ <EffectComposer ref={composerRef}>
168
+ <Outline ref={effectRef} resolutionScale={resolutionScale} />
169
+ </EffectComposer>
170
+ )
171
+
172
+ await React.act(async () => render(0.5))
173
+ await waitForComposer(composerRef)
174
+ await flush()
175
+ const first = effectRef.current
176
+
177
+ await React.act(async () => render(1))
178
+ await flush()
179
+
180
+ expect(effectRef.current).not.toBe(first)
181
+ })
182
+
183
+ it('does not dispose its render target on unrelated re-renders (multisampling has an unconditional dispose side effect)', async () => {
184
+ const composerRef = React.createRef<EffectComposerImpl>()
185
+ const effectRef = React.createRef<OutlineEffect>()
186
+
187
+ const render = (tick: number) =>
188
+ root.render(
189
+ <EffectComposer ref={composerRef}>
190
+ <Outline ref={effectRef} />
191
+ <group name={`tick-${tick}`} />
192
+ </EffectComposer>
193
+ )
194
+
195
+ await React.act(async () => render(0))
196
+ await waitForComposer(composerRef)
197
+ await flush()
198
+
199
+ // @ts-expect-error - `renderTargetMask` isn't part of the public OutlineEffect typing
200
+ const disposeSpy = vi.spyOn(effectRef.current!.renderTargetMask, 'dispose')
201
+
202
+ for (let t = 1; t <= 5; t++) {
203
+ await React.act(async () => render(t))
204
+ await flush()
205
+ }
206
+
207
+ expect(disposeSpy).not.toHaveBeenCalled()
208
+ disposeSpy.mockRestore()
209
+ })
210
+
211
+ })
@@ -0,0 +1,140 @@
1
+ import { EffectComposer as EffectComposerImpl, SSAOEffect } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { Color } from 'three'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { EffectComposer } from '../EffectComposer'
6
+ import { SSAO } from '../effects/SSAO'
7
+ import { flush, root, waitForComposer } from './test-utils'
8
+
9
+ describe('SSAO', () => {
10
+ it('resets color/fade/minRadiusScale to their constructor defaults when removed, not the first-mounted value', async () => {
11
+ const composerRef = React.createRef<EffectComposerImpl>()
12
+ const ref = React.createRef<SSAOEffect>()
13
+
14
+ const render = (withOverrides: boolean) =>
15
+ root.render(
16
+ <EffectComposer ref={composerRef} enableNormalPass>
17
+ <SSAO ref={ref} {...(withOverrides ? { color: new Color('red'), fade: 0.5, minRadiusScale: 0.9 } : {})} />
18
+ </EffectComposer>
19
+ )
20
+
21
+ await React.act(async () => render(true))
22
+ await waitForComposer(composerRef)
23
+ await flush()
24
+
25
+ expect(ref.current!.color!.getHexString()).toBe('ff0000')
26
+ expect(ref.current!.ssaoMaterial.fade).toBeCloseTo(0.5)
27
+ expect(ref.current!.ssaoMaterial.minRadiusScale).toBeCloseTo(0.9)
28
+
29
+ await React.act(async () => render(false))
30
+ await flush()
31
+
32
+ // SSAOEffect's own constructor defaults (null / 0.01 / 0.1), not the
33
+ // values from the first render this instance ever saw.
34
+ expect(ref.current!.color).toBeNull()
35
+ expect(ref.current!.ssaoMaterial.fade).toBeCloseTo(0.01)
36
+ expect(ref.current!.ssaoMaterial.minRadiusScale).toBeCloseTo(0.1)
37
+ })
38
+
39
+ it('applies intensity live, without reconstructing the effect', async () => {
40
+ const composerRef = React.createRef<EffectComposerImpl>()
41
+ const ref = React.createRef<SSAOEffect>()
42
+
43
+ const render = (intensity: number) =>
44
+ root.render(
45
+ <EffectComposer ref={composerRef} enableNormalPass>
46
+ <SSAO ref={ref} intensity={intensity} />
47
+ </EffectComposer>
48
+ )
49
+
50
+ await React.act(async () => render(1))
51
+ await waitForComposer(composerRef)
52
+ await flush()
53
+ const first = ref.current
54
+ expect(first!.intensity).toBe(1)
55
+
56
+ await React.act(async () => render(2))
57
+ await flush()
58
+
59
+ expect(ref.current).toBe(first)
60
+ expect(ref.current!.intensity).toBe(2)
61
+
62
+ await React.act(async () => root.render(null))
63
+ })
64
+
65
+ it('applies bias live via the nested ssaoMaterial, without reconstructing the effect', async () => {
66
+ const composerRef = React.createRef<EffectComposerImpl>()
67
+ const ref = React.createRef<SSAOEffect>()
68
+
69
+ const render = (bias: number) =>
70
+ root.render(
71
+ <EffectComposer ref={composerRef} enableNormalPass>
72
+ <SSAO ref={ref} bias={bias} />
73
+ </EffectComposer>
74
+ )
75
+
76
+ await React.act(async () => render(0.5))
77
+ await waitForComposer(composerRef)
78
+ await flush()
79
+ const first = ref.current
80
+ expect(first!.ssaoMaterial.bias).toBeCloseTo(0.5)
81
+
82
+ await React.act(async () => render(0.8))
83
+ await flush()
84
+
85
+ expect(ref.current).toBe(first)
86
+ expect(ref.current!.ssaoMaterial.bias).toBeCloseTo(0.8)
87
+
88
+ await React.act(async () => root.render(null))
89
+ })
90
+
91
+ it('applies worldProximityThreshold live via the nested ssaoMaterial, without reconstructing the effect', async () => {
92
+ const composerRef = React.createRef<EffectComposerImpl>()
93
+ const ref = React.createRef<SSAOEffect>()
94
+
95
+ const render = (worldProximityThreshold: number) =>
96
+ root.render(
97
+ <EffectComposer ref={composerRef} enableNormalPass>
98
+ <SSAO ref={ref} worldProximityThreshold={worldProximityThreshold} />
99
+ </EffectComposer>
100
+ )
101
+
102
+ await React.act(async () => render(0.5))
103
+ await waitForComposer(composerRef)
104
+ await flush()
105
+ const first = ref.current
106
+ expect(first!.ssaoMaterial.worldProximityThreshold).toBeCloseTo(0.5)
107
+
108
+ await React.act(async () => render(0.8))
109
+ await flush()
110
+
111
+ expect(ref.current).toBe(first)
112
+ expect(ref.current!.ssaoMaterial.worldProximityThreshold).toBeCloseTo(0.8)
113
+
114
+ await React.act(async () => root.render(null))
115
+ })
116
+
117
+ it('still reconstructs when resolutionScale (construction-only) changes', async () => {
118
+ const composerRef = React.createRef<EffectComposerImpl>()
119
+ const ref = React.createRef<SSAOEffect>()
120
+
121
+ const render = (resolutionScale: number) =>
122
+ root.render(
123
+ <EffectComposer ref={composerRef} enableNormalPass>
124
+ <SSAO ref={ref} resolutionScale={resolutionScale} />
125
+ </EffectComposer>
126
+ )
127
+
128
+ await React.act(async () => render(0.5))
129
+ await waitForComposer(composerRef)
130
+ await flush()
131
+ const first = ref.current
132
+
133
+ await React.act(async () => render(1))
134
+ await flush()
135
+
136
+ expect(ref.current).not.toBe(first)
137
+
138
+ await React.act(async () => root.render(null))
139
+ })
140
+ })
@@ -0,0 +1,324 @@
1
+ import * as React from 'react'
2
+ import * as THREE from 'three'
3
+ import { afterEach, describe, expect, it } from 'vitest'
4
+ import { Select, Selection, selectionContext } from '../Selection'
5
+ import { flush, root } from './test-utils'
6
+
7
+ afterEach(async () => {
8
+ await React.act(async () => {
9
+ root.render(null)
10
+ })
11
+ })
12
+
13
+ function Capture({ onSnapshot }: { onSnapshot: (selected: THREE.Object3D[]) => void }) {
14
+ const api = React.useContext(selectionContext)
15
+ React.useEffect(() => {
16
+ onSnapshot(api?.selected ?? [])
17
+ })
18
+ return null
19
+ }
20
+
21
+ describe('Selection/Select', () => {
22
+ it('settles to a stable selected array without looping', async () => {
23
+ const snapshots: THREE.Object3D[][] = []
24
+ const meshRef = React.createRef<THREE.Mesh>()
25
+
26
+ await React.act(async () =>
27
+ root.render(
28
+ <Selection>
29
+ <Capture onSnapshot={(s) => snapshots.push(s)} />
30
+ <Select enabled>
31
+ <mesh ref={meshRef}>
32
+ <boxGeometry />
33
+ <meshBasicMaterial />
34
+ </mesh>
35
+ </Select>
36
+ </Selection>
37
+ )
38
+ )
39
+ await flush()
40
+ await flush()
41
+
42
+ // Exactly one render for the initial (empty) commit, one for the
43
+ // single necessary addition - the original bug kept re-triggering
44
+ // itself, so this would grow unbounded (and eventually throw) instead
45
+ // of stopping at 2.
46
+ expect(snapshots).toEqual([[], [meshRef.current]])
47
+ })
48
+
49
+ it('selects a Line and a Points object, not just Mesh', async () => {
50
+ const snapshots: THREE.Object3D[][] = []
51
+ const lineRef = React.createRef<THREE.Line>()
52
+ const pointsRef = React.createRef<THREE.Points>()
53
+
54
+ await React.act(async () =>
55
+ root.render(
56
+ <Selection>
57
+ <Capture onSnapshot={(s) => snapshots.push(s)} />
58
+ <Select enabled>
59
+ <threeLine ref={lineRef}>
60
+ <bufferGeometry />
61
+ <lineBasicMaterial />
62
+ </threeLine>
63
+ </Select>
64
+ <Select enabled>
65
+ <points ref={pointsRef}>
66
+ <bufferGeometry />
67
+ <pointsMaterial />
68
+ </points>
69
+ </Select>
70
+ </Selection>
71
+ )
72
+ )
73
+ await flush()
74
+ await flush()
75
+
76
+ const last = snapshots[snapshots.length - 1]
77
+ expect(last).toContain(lineRef.current)
78
+ expect(last).toContain(pointsRef.current)
79
+ })
80
+
81
+ it('removes its objects once unmounted, leaving a sibling Select untouched', async () => {
82
+ const snapshots: THREE.Object3D[][] = []
83
+ const meshARef = React.createRef<THREE.Mesh>()
84
+ const meshBRef = React.createRef<THREE.Mesh>()
85
+
86
+ const render = (mountA: boolean) =>
87
+ root.render(
88
+ <Selection>
89
+ <Capture onSnapshot={(s) => snapshots.push(s)} />
90
+ {mountA && (
91
+ <Select key="a" enabled>
92
+ <mesh ref={meshARef}>
93
+ <boxGeometry />
94
+ <meshBasicMaterial />
95
+ </mesh>
96
+ </Select>
97
+ )}
98
+ <Select key="b" enabled>
99
+ <mesh ref={meshBRef}>
100
+ <boxGeometry />
101
+ <meshBasicMaterial />
102
+ </mesh>
103
+ </Select>
104
+ </Selection>
105
+ )
106
+
107
+ await React.act(async () => render(true))
108
+ await flush()
109
+ await flush()
110
+ expect(snapshots[snapshots.length - 1]).toEqual(expect.arrayContaining([meshARef.current, meshBRef.current]))
111
+
112
+ await React.act(async () => render(false))
113
+ await flush()
114
+ await flush()
115
+
116
+ const last = snapshots[snapshots.length - 1]
117
+ expect(last).not.toContain(meshARef.current)
118
+ expect(last).toContain(meshBRef.current)
119
+ })
120
+
121
+ it('removes its objects when enabled toggles to false', async () => {
122
+ const snapshots: THREE.Object3D[][] = []
123
+ const meshRef = React.createRef<THREE.Mesh>()
124
+
125
+ const render = (enabled: boolean) =>
126
+ root.render(
127
+ <Selection>
128
+ <Capture onSnapshot={(s) => snapshots.push(s)} />
129
+ <Select enabled={enabled}>
130
+ <mesh ref={meshRef}>
131
+ <boxGeometry />
132
+ <meshBasicMaterial />
133
+ </mesh>
134
+ </Select>
135
+ </Selection>
136
+ )
137
+
138
+ await React.act(async () => render(true))
139
+ await flush()
140
+ await flush()
141
+ expect(snapshots[snapshots.length - 1]).toContain(meshRef.current)
142
+
143
+ await React.act(async () => render(false))
144
+ await flush()
145
+ await flush()
146
+ expect(snapshots[snapshots.length - 1]).not.toContain(meshRef.current)
147
+ })
148
+
149
+ it('selects a SkinnedMesh - isMesh is inherited, not just the exact type string', async () => {
150
+ const snapshots: THREE.Object3D[][] = []
151
+ const meshRef = React.createRef<THREE.SkinnedMesh>()
152
+
153
+ await React.act(async () =>
154
+ root.render(
155
+ <Selection>
156
+ <Capture onSnapshot={(s) => snapshots.push(s)} />
157
+ <Select enabled>
158
+ <skinnedMesh ref={meshRef} />
159
+ </Select>
160
+ </Selection>
161
+ )
162
+ )
163
+ await flush()
164
+ await flush()
165
+
166
+ expect(snapshots[snapshots.length - 1]).toContain(meshRef.current)
167
+ })
168
+
169
+ it('does not duplicate an object claimed by nested Selects', async () => {
170
+ const snapshots: THREE.Object3D[][] = []
171
+ const meshRef = React.createRef<THREE.Mesh>()
172
+
173
+ await React.act(async () =>
174
+ root.render(
175
+ <Selection>
176
+ <Capture onSnapshot={(s) => snapshots.push(s)} />
177
+ <Select enabled>
178
+ <Select enabled>
179
+ <mesh ref={meshRef}>
180
+ <boxGeometry />
181
+ <meshBasicMaterial />
182
+ </mesh>
183
+ </Select>
184
+ </Select>
185
+ </Selection>
186
+ )
187
+ )
188
+ await flush()
189
+ await flush()
190
+ await flush()
191
+
192
+ const last = snapshots[snapshots.length - 1]
193
+ expect(last.filter((o) => o === meshRef.current)).toHaveLength(1)
194
+ })
195
+
196
+ it('keeps an object selected via the outer Select after the inner one disables', async () => {
197
+ const snapshots: THREE.Object3D[][] = []
198
+ const meshRef = React.createRef<THREE.Mesh>()
199
+
200
+ const render = (innerEnabled: boolean) =>
201
+ root.render(
202
+ <Selection>
203
+ <Capture onSnapshot={(s) => snapshots.push(s)} />
204
+ <Select enabled>
205
+ <Select enabled={innerEnabled}>
206
+ <mesh ref={meshRef}>
207
+ <boxGeometry />
208
+ <meshBasicMaterial />
209
+ </mesh>
210
+ </Select>
211
+ </Select>
212
+ </Selection>
213
+ )
214
+
215
+ await React.act(async () => render(true))
216
+ await flush()
217
+ await flush()
218
+ await flush()
219
+ expect(snapshots[snapshots.length - 1]).toContain(meshRef.current)
220
+
221
+ await React.act(async () => render(false))
222
+ await flush()
223
+ await flush()
224
+ await flush()
225
+
226
+ // The inner Select's own cleanup drops its claim, but the mesh never
227
+ // left the outer Select's subtree - its own traversal still finds it.
228
+ expect(snapshots[snapshots.length - 1]).toContain(meshRef.current)
229
+ })
230
+
231
+ it('does not change the selected array reference on a re-render where children only changes identity', async () => {
232
+ const refs: THREE.Object3D[][] = []
233
+ const meshRef = React.createRef<THREE.Mesh>()
234
+
235
+ function CaptureRef() {
236
+ const api = React.useContext(selectionContext)
237
+ React.useEffect(() => {
238
+ if (api) refs.push(api.selected)
239
+ })
240
+ return null
241
+ }
242
+
243
+ // A fresh JSX tree every call, like a parent re-rendering for an
244
+ // unrelated reason - Select's own `children` prop is a new reference
245
+ // each time even though the underlying mesh never changes.
246
+ const render = () =>
247
+ root.render(
248
+ <Selection>
249
+ <CaptureRef />
250
+ <Select enabled>
251
+ <mesh ref={meshRef}>
252
+ <boxGeometry />
253
+ <meshBasicMaterial />
254
+ </mesh>
255
+ </Select>
256
+ </Selection>
257
+ )
258
+
259
+ await React.act(async () => render())
260
+ await flush()
261
+ await flush()
262
+
263
+ refs.length = 0
264
+ for (let i = 0; i < 5; i++) {
265
+ await React.act(async () => render())
266
+ await flush()
267
+ }
268
+
269
+ expect(new Set(refs).size).toBe(1)
270
+ })
271
+
272
+ it('a cleanup updater returns the same reference when nothing is left for it to remove', async () => {
273
+ const meshRef = React.createRef<THREE.Mesh>()
274
+ const updaters: Array<(prev: THREE.Object3D[]) => THREE.Object3D[]> = []
275
+
276
+ // React batches both nested Selects' cleanup calls into a single
277
+ // re-render regardless of this bail-out (the net change from mount to
278
+ // unmount is real), so the optimization isn't observable through
279
+ // rendering alone. Capture the raw updater functions instead and
280
+ // replay them directly to verify the second one bails.
281
+ function CapturingSelection({ children }: { children: React.ReactNode }) {
282
+ const [selected, setSelected] = React.useState<THREE.Object3D[]>([])
283
+ const select = React.useCallback((updater: React.SetStateAction<THREE.Object3D[]>) => {
284
+ if (typeof updater === 'function') updaters.push(updater as (prev: THREE.Object3D[]) => THREE.Object3D[])
285
+ setSelected(updater)
286
+ }, [])
287
+ const value = React.useMemo(() => ({ selected, select, enabled: true }), [selected, select])
288
+ return <selectionContext.Provider value={value}>{children}</selectionContext.Provider>
289
+ }
290
+
291
+ const render = (mounted: boolean) =>
292
+ root.render(
293
+ <CapturingSelection>
294
+ {mounted && (
295
+ <Select enabled>
296
+ <Select enabled>
297
+ <mesh ref={meshRef}>
298
+ <boxGeometry />
299
+ <meshBasicMaterial />
300
+ </mesh>
301
+ </Select>
302
+ </Select>
303
+ )}
304
+ </CapturingSelection>
305
+ )
306
+
307
+ await React.act(async () => render(true))
308
+ await flush()
309
+ await flush()
310
+ await flush()
311
+
312
+ const mesh = meshRef.current!
313
+ updaters.length = 0
314
+ await React.act(async () => render(false))
315
+ await flush()
316
+ await flush()
317
+ await flush()
318
+
319
+ expect(updaters).toHaveLength(2)
320
+ const afterFirst = updaters[0]([mesh])
321
+ const afterSecond = updaters[1](afterFirst)
322
+ expect(afterSecond).toBe(afterFirst)
323
+ })
324
+ })