@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,166 @@
1
+ import { EffectComposer as EffectComposerImpl, SelectiveBloomEffect } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { Mesh, Object3D, PointLight } from 'three'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { EffectComposer } from '../EffectComposer'
6
+ import { SelectiveBloom } from '../effects/SelectiveBloom'
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('SelectiveBloom', () => {
17
+ it('does not reconstruct the effect (with its GPU resources) on unrelated re-renders', async () => {
18
+ const composerRef = React.createRef<EffectComposerImpl>()
19
+ const effectRef = React.createRef<SelectiveBloomEffect>()
20
+ const light = new PointLight()
21
+
22
+ const render = (tick: number) =>
23
+ root.render(
24
+ <EffectComposer ref={composerRef}>
25
+ <SelectiveBloom ref={effectRef} lights={[light]} intensity={2} />
26
+ <group name={`tick-${tick}`} />
27
+ </EffectComposer>
28
+ )
29
+
30
+ await React.act(async () => render(0))
31
+ await waitForComposer(composerRef)
32
+ await flush()
33
+ const first = effectRef.current
34
+ expect(first).toBeTruthy()
35
+
36
+ for (let t = 1; t <= 5; t++) {
37
+ await React.act(async () => render(t))
38
+ await flush()
39
+ }
40
+
41
+ expect(effectRef.current).toBe(first)
42
+ })
43
+
44
+ it('sets its selection from the Selection/Select API and clears it when the object deselects', async () => {
45
+ const effectRef = React.createRef<SelectiveBloomEffect>()
46
+ const meshRef = React.createRef<Mesh>()
47
+ const light = new PointLight()
48
+
49
+ const render = (enabled: boolean) =>
50
+ root.render(
51
+ <EffectComposer>
52
+ <Selection>
53
+ <Select enabled={enabled}>
54
+ <mesh ref={meshRef}>
55
+ <boxGeometry />
56
+ <meshBasicMaterial />
57
+ </mesh>
58
+ </Select>
59
+ <SelectiveBloom ref={effectRef} lights={[light]} />
60
+ </Selection>
61
+ </EffectComposer>
62
+ )
63
+
64
+ await React.act(async () => render(true))
65
+ await flush()
66
+ await flush()
67
+
68
+ expect(Array.from(effectRef.current!.selection)).toContain(meshRef.current)
69
+
70
+ await React.act(async () => render(false))
71
+ await flush()
72
+ await flush()
73
+
74
+ expect(Array.from(effectRef.current!.selection)).not.toContain(meshRef.current)
75
+ })
76
+
77
+ it('moves lights to the new render layer when selectionLayer changes', async () => {
78
+ const composerRef = React.createRef<EffectComposerImpl>()
79
+ const effectRef = React.createRef<SelectiveBloomEffect>()
80
+ const light = new PointLight()
81
+ const onLayer = (n: number) => light.layers.test({ mask: 1 << n } as never)
82
+
83
+ const render = (layer: number) =>
84
+ root.render(
85
+ <EffectComposer ref={composerRef}>
86
+ <SelectiveBloom ref={effectRef} lights={[light]} selectionLayer={layer} />
87
+ </EffectComposer>
88
+ )
89
+
90
+ await React.act(async () => render(10))
91
+ await waitForComposer(composerRef)
92
+ await flush()
93
+ await flush()
94
+ expect(onLayer(10)).toBe(true)
95
+
96
+ await React.act(async () => render(15))
97
+ await flush()
98
+ await flush()
99
+
100
+ expect(onLayer(15)).toBe(true)
101
+ expect(onLayer(10)).toBe(false)
102
+ })
103
+
104
+ it('does not throw when a lights ref has not attached yet', async () => {
105
+ const composerRef = React.createRef<EffectComposerImpl>()
106
+ const unattachedRef = React.createRef<Object3D>()
107
+
108
+ await React.act(async () =>
109
+ root.render(
110
+ <EffectComposer ref={composerRef}>
111
+ <SelectiveBloom lights={[unattachedRef]} />
112
+ </EffectComposer>
113
+ )
114
+ )
115
+ await waitForComposer(composerRef)
116
+ await expect(flush()).resolves.not.toThrow()
117
+ })
118
+
119
+ it('applies intensity live, without reconstructing the effect', async () => {
120
+ const composerRef = React.createRef<EffectComposerImpl>()
121
+ const effectRef = React.createRef<SelectiveBloomEffect>()
122
+ const light = new PointLight()
123
+
124
+ const render = (intensity: number) =>
125
+ root.render(
126
+ <EffectComposer ref={composerRef}>
127
+ <SelectiveBloom ref={effectRef} lights={[light]} intensity={intensity} />
128
+ </EffectComposer>
129
+ )
130
+
131
+ await React.act(async () => render(1))
132
+ await waitForComposer(composerRef)
133
+ await flush()
134
+ const first = effectRef.current
135
+ expect(first!.intensity).toBe(1)
136
+
137
+ await React.act(async () => render(3))
138
+ await flush()
139
+
140
+ expect(effectRef.current).toBe(first)
141
+ expect(effectRef.current!.intensity).toBe(3)
142
+ })
143
+
144
+ it('still reconstructs when luminanceThreshold changes (no live setter in postprocessing)', async () => {
145
+ const composerRef = React.createRef<EffectComposerImpl>()
146
+ const effectRef = React.createRef<SelectiveBloomEffect>()
147
+ const light = new PointLight()
148
+
149
+ const render = (luminanceThreshold: number) =>
150
+ root.render(
151
+ <EffectComposer ref={composerRef}>
152
+ <SelectiveBloom ref={effectRef} lights={[light]} luminanceThreshold={luminanceThreshold} />
153
+ </EffectComposer>
154
+ )
155
+
156
+ await React.act(async () => render(0.5))
157
+ await waitForComposer(composerRef)
158
+ await flush()
159
+ const first = effectRef.current
160
+
161
+ await React.act(async () => render(0.8))
162
+ await flush()
163
+
164
+ expect(effectRef.current).not.toBe(first)
165
+ })
166
+ })
@@ -0,0 +1,95 @@
1
+ import { EffectComposer as EffectComposerImpl, ShockWaveEffect } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { Vector3 } from 'three'
4
+ import { describe, expect, it } from 'vitest'
5
+ import { EffectComposer } from '../EffectComposer'
6
+ import { ShockWave } from '../effects/ShockWave'
7
+ import { flush, root } from './test-utils'
8
+
9
+ describe('ShockWave', () => {
10
+ it('applies speed and position, which createEffectComponent cannot (ShockWaveEffect takes them as a 3rd ctor arg)', async () => {
11
+ const ref = React.createRef<ShockWaveEffect>()
12
+ const composerRef = React.createRef<EffectComposerImpl>()
13
+ const position = new Vector3(1, 2, 3)
14
+
15
+ await React.act(async () =>
16
+ root.render(
17
+ <EffectComposer ref={composerRef}>
18
+ <ShockWave ref={ref} speed={5} position={position} />
19
+ </EffectComposer>
20
+ )
21
+ )
22
+
23
+ await flush()
24
+
25
+ expect(ref.current!.speed).toBe(5)
26
+ expect(ref.current!.position).toBe(position)
27
+
28
+ await React.act(async () => root.render(null))
29
+ })
30
+
31
+ it('updates speed/position live, without reconstructing the instance', async () => {
32
+ const ref = React.createRef<ShockWaveEffect>()
33
+ const composerRef = React.createRef<EffectComposerImpl>()
34
+
35
+ await React.act(async () =>
36
+ root.render(
37
+ <EffectComposer ref={composerRef}>
38
+ <ShockWave ref={ref} speed={1} />
39
+ </EffectComposer>
40
+ )
41
+ )
42
+
43
+ await flush()
44
+
45
+ const firstInstance = ref.current
46
+
47
+ await React.act(async () =>
48
+ root.render(
49
+ <EffectComposer ref={composerRef}>
50
+ <ShockWave ref={ref} speed={2} waveSize={0.5} />
51
+ </EffectComposer>
52
+ )
53
+ )
54
+
55
+ await flush()
56
+
57
+ expect(ref.current).toBe(firstInstance)
58
+ expect(ref.current!.speed).toBe(2)
59
+ expect(ref.current!.waveSize).toBe(0.5)
60
+
61
+ await React.act(async () => root.render(null))
62
+ })
63
+
64
+ it('resets speed to its constructor default when the prop is removed', async () => {
65
+ const ref = React.createRef<ShockWaveEffect>()
66
+ const composerRef = React.createRef<EffectComposerImpl>()
67
+
68
+ await React.act(async () =>
69
+ root.render(
70
+ <EffectComposer ref={composerRef}>
71
+ <ShockWave ref={ref} speed={5} />
72
+ </EffectComposer>
73
+ )
74
+ )
75
+
76
+ await flush()
77
+
78
+ expect(ref.current!.speed).toBe(5)
79
+ const defaultSpeed = 2
80
+
81
+ await React.act(async () =>
82
+ root.render(
83
+ <EffectComposer ref={composerRef}>
84
+ <ShockWave ref={ref} />
85
+ </EffectComposer>
86
+ )
87
+ )
88
+
89
+ await flush()
90
+
91
+ expect(ref.current!.speed).toBe(defaultSpeed)
92
+
93
+ await React.act(async () => root.render(null))
94
+ })
95
+ })
@@ -0,0 +1,76 @@
1
+ import { EffectComposer as EffectComposerImpl, TiltShiftEffect } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { EffectComposer } from '../EffectComposer'
5
+ import { TiltShift } from '../effects/TiltShift'
6
+ import { flush, root } from './test-utils'
7
+
8
+ describe('TiltShift', () => {
9
+ it('applies resolutionScale at construction (previously never reached the effect at all)', async () => {
10
+ const composerRef = React.createRef<EffectComposerImpl>()
11
+ const ref = React.createRef<TiltShiftEffect>()
12
+
13
+ await React.act(async () =>
14
+ root.render(
15
+ <EffectComposer ref={composerRef}>
16
+ <TiltShift ref={ref} resolutionScale={0.25} />
17
+ </EffectComposer>
18
+ )
19
+ )
20
+
21
+ await flush()
22
+
23
+ expect(ref.current!.blurPass.resolution.scale).toBeCloseTo(0.25)
24
+
25
+ await React.act(async () => root.render(null))
26
+ })
27
+
28
+ it('reconstructs when resolutionScale (construction-only) changes', async () => {
29
+ const composerRef = React.createRef<EffectComposerImpl>()
30
+ const ref = React.createRef<TiltShiftEffect>()
31
+
32
+ const render = (resolutionScale: number) =>
33
+ root.render(
34
+ <EffectComposer ref={composerRef}>
35
+ <TiltShift ref={ref} resolutionScale={resolutionScale} />
36
+ </EffectComposer>
37
+ )
38
+
39
+ await React.act(async () => render(0.25))
40
+ await flush()
41
+ const first = ref.current
42
+
43
+ await React.act(async () => render(0.5))
44
+ await flush()
45
+
46
+ expect(ref.current).not.toBe(first)
47
+ expect(ref.current!.blurPass.resolution.scale).toBeCloseTo(0.5)
48
+
49
+ await React.act(async () => root.render(null))
50
+ })
51
+
52
+ it('applies offset live, without reconstructing the effect', async () => {
53
+ const composerRef = React.createRef<EffectComposerImpl>()
54
+ const ref = React.createRef<TiltShiftEffect>()
55
+
56
+ const render = (offset: number) =>
57
+ root.render(
58
+ <EffectComposer ref={composerRef}>
59
+ <TiltShift ref={ref} offset={offset} />
60
+ </EffectComposer>
61
+ )
62
+
63
+ await React.act(async () => render(0.1))
64
+ await flush()
65
+ const first = ref.current
66
+ expect(first!.offset).toBeCloseTo(0.1)
67
+
68
+ await React.act(async () => render(0.2))
69
+ await flush()
70
+
71
+ expect(ref.current).toBe(first)
72
+ expect(ref.current!.offset).toBeCloseTo(0.2)
73
+
74
+ await React.act(async () => root.render(null))
75
+ })
76
+ })
@@ -0,0 +1,286 @@
1
+ import { Effect, EffectComposer as EffectComposerImpl } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { Uniform } from 'three'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { createEffectComponent } from '../createEffectComponent'
6
+ import { EffectComposer } from '../EffectComposer'
7
+ import { flush, root } from './test-utils'
8
+
9
+ // Zero-arity, real accessor - the class of effect createEffectComponent
10
+ // targets. Matches BloomEffect's shape: `constructor({...} = {})`.
11
+ class FakeEffect extends Effect {
12
+ private _value: number
13
+ constructor({ value = 0 }: { value?: number } = {}) {
14
+ super('FakeEffect', 'mainImage() {}')
15
+ this._value = value
16
+ }
17
+ get value() {
18
+ return this._value
19
+ }
20
+ set value(v: number) {
21
+ this._value = v
22
+ }
23
+ }
24
+
25
+ const FakeEffectComponent = /* @__PURE__ */ createEffectComponent<typeof FakeEffect, { value?: number }>(FakeEffect)
26
+
27
+ // Options stored only in `uniforms` (mirrors WaterEffectImpl/RampEffect
28
+ // before this branch gave them real accessors) - `factor` here HAS a real
29
+ // accessor, proving that's what makes a plain JSX prop actually reach it.
30
+ class UniformFixtureEffect extends Effect {
31
+ constructor({ factor = 0 }: { factor?: number } = {}) {
32
+ super('UniformFixtureEffect', 'mainImage() {}', { uniforms: new Map([['factor', new Uniform(factor)]]) })
33
+ }
34
+ get factor(): number {
35
+ return this.uniforms.get('factor')!.value
36
+ }
37
+ set factor(v: number) {
38
+ this.uniforms.get('factor')!.value = v
39
+ }
40
+ }
41
+
42
+ const UniformFixtureComponent = /* @__PURE__ */ createEffectComponent<typeof UniformFixtureEffect, { factor?: number }>(
43
+ UniformFixtureEffect
44
+ )
45
+
46
+ describe('createEffectComponent', () => {
47
+ it('constructs the effect and passes props through to the instance', async () => {
48
+ const composerRef = React.createRef<EffectComposerImpl>()
49
+
50
+ await React.act(async () =>
51
+ root.render(
52
+ <EffectComposer ref={composerRef}>
53
+ <FakeEffectComponent value={42} />
54
+ </EffectComposer>
55
+ )
56
+ )
57
+
58
+ await flush()
59
+
60
+ // @ts-expect-error - `effects` isn't part of the public Pass typing
61
+ const effect = composerRef.current!.passes[1].effects[0]
62
+
63
+ expect(effect).toBeInstanceOf(FakeEffect)
64
+ expect(effect.value).toBe(42)
65
+
66
+ await React.act(async () => root.render(null))
67
+ })
68
+
69
+ it('applies a live prop without reconstructing the instance (r3f-native, no args change)', async () => {
70
+ const composerRef = React.createRef<EffectComposerImpl>()
71
+ const ref = React.createRef<FakeEffect>()
72
+
73
+ const render = (value: number) =>
74
+ root.render(
75
+ <EffectComposer ref={composerRef}>
76
+ <FakeEffectComponent ref={ref} value={value} />
77
+ </EffectComposer>
78
+ )
79
+
80
+ await React.act(async () => render(1))
81
+ await flush()
82
+ const first = ref.current
83
+ expect(first!.value).toBe(1)
84
+
85
+ await React.act(async () => render(2))
86
+ await flush()
87
+
88
+ expect(ref.current).toBe(first)
89
+ expect(ref.current!.value).toBe(2)
90
+
91
+ await React.act(async () => root.render(null))
92
+ })
93
+
94
+ it('resets a live prop to its constructor default when removed (r3f-native diffProps)', async () => {
95
+ const composerRef = React.createRef<EffectComposerImpl>()
96
+ const ref = React.createRef<FakeEffect>()
97
+
98
+ await React.act(async () =>
99
+ root.render(
100
+ <EffectComposer ref={composerRef}>
101
+ <FakeEffectComponent ref={ref} value={5} />
102
+ </EffectComposer>
103
+ )
104
+ )
105
+ await flush()
106
+ expect(ref.current!.value).toBe(5)
107
+
108
+ await React.act(async () =>
109
+ root.render(
110
+ <EffectComposer ref={composerRef}>
111
+ <FakeEffectComponent ref={ref} />
112
+ </EffectComposer>
113
+ )
114
+ )
115
+ await flush()
116
+
117
+ expect(ref.current!.value).toBe(0)
118
+
119
+ await React.act(async () => root.render(null))
120
+ })
121
+
122
+ it('reconstructs when an explicit args prop changes, same as any other r3f element', async () => {
123
+ const composerRef = React.createRef<EffectComposerImpl>()
124
+ const ref = React.createRef<FakeEffect>()
125
+
126
+ const render = (value: number) =>
127
+ root.render(
128
+ <EffectComposer ref={composerRef}>
129
+ <FakeEffectComponent ref={ref} args={[{ value }]} />
130
+ </EffectComposer>
131
+ )
132
+
133
+ await React.act(async () => render(1))
134
+ await flush()
135
+ const first = ref.current
136
+ expect(first!.value).toBe(1)
137
+
138
+ await React.act(async () => render(2))
139
+ await flush()
140
+
141
+ expect(ref.current).not.toBe(first)
142
+ expect(ref.current!.value).toBe(2)
143
+
144
+ await React.act(async () => root.render(null))
145
+ })
146
+
147
+ it('applies blendFunction/opacity through blendMode, not as a stray top-level property', async () => {
148
+ const composerRef = React.createRef<EffectComposerImpl>()
149
+ const ref = React.createRef<FakeEffect>()
150
+
151
+ await React.act(async () =>
152
+ root.render(
153
+ <EffectComposer ref={composerRef}>
154
+ <FakeEffectComponent ref={ref} blendFunction={7 as never} opacity={0.5} />
155
+ </EffectComposer>
156
+ )
157
+ )
158
+ await flush()
159
+
160
+ expect(ref.current!.blendMode.blendFunction).toBe(7)
161
+ expect(ref.current!.blendMode.opacity.value).toBe(0.5)
162
+ expect((ref.current as unknown as { blendFunction?: unknown }).blendFunction).toBeUndefined()
163
+
164
+ await React.act(async () => root.render(null))
165
+ })
166
+
167
+ it('resets blendFunction/opacity to blendMode\'s own defaults when the props are removed', async () => {
168
+ const composerRef = React.createRef<EffectComposerImpl>()
169
+ const ref = React.createRef<FakeEffect>()
170
+
171
+ await React.act(async () =>
172
+ root.render(
173
+ <EffectComposer ref={composerRef}>
174
+ <FakeEffectComponent ref={ref} />
175
+ </EffectComposer>
176
+ )
177
+ )
178
+ await flush()
179
+ const defaultBlendFunction = ref.current!.blendMode.blendFunction
180
+ const defaultOpacity = ref.current!.blendMode.opacity.value
181
+
182
+ await React.act(async () =>
183
+ root.render(
184
+ <EffectComposer ref={composerRef}>
185
+ <FakeEffectComponent ref={ref} blendFunction={7 as never} opacity={0.5} />
186
+ </EffectComposer>
187
+ )
188
+ )
189
+ await flush()
190
+ expect(ref.current!.blendMode.blendFunction).toBe(7)
191
+ expect(ref.current!.blendMode.opacity.value).toBe(0.5)
192
+
193
+ await React.act(async () =>
194
+ root.render(
195
+ <EffectComposer ref={composerRef}>
196
+ <FakeEffectComponent ref={ref} />
197
+ </EffectComposer>
198
+ )
199
+ )
200
+ await flush()
201
+
202
+ expect(ref.current!.blendMode.blendFunction).toBe(defaultBlendFunction)
203
+ expect(ref.current!.blendMode.opacity.value).toBe(defaultOpacity)
204
+
205
+ await React.act(async () => root.render(null))
206
+ })
207
+
208
+ it('updates a uniforms-Map-backed prop live via its accessor, without reconstructing', async () => {
209
+ const composerRef = React.createRef<EffectComposerImpl>()
210
+ const ref = React.createRef<UniformFixtureEffect>()
211
+
212
+ const render = (factor: number) =>
213
+ root.render(
214
+ <EffectComposer ref={composerRef}>
215
+ <UniformFixtureComponent ref={ref} factor={factor} />
216
+ </EffectComposer>
217
+ )
218
+
219
+ await React.act(async () => render(1))
220
+ await flush()
221
+ const first = ref.current
222
+ expect(first!.uniforms.get('factor')!.value).toBe(1)
223
+
224
+ await React.act(async () => render(2))
225
+ await flush()
226
+
227
+ expect(ref.current).toBe(first)
228
+ expect(ref.current!.uniforms.get('factor')!.value).toBe(2)
229
+
230
+ await React.act(async () => root.render(null))
231
+ })
232
+
233
+ it('disposes the instance on unmount', async () => {
234
+ const disposeSpy = vi.spyOn(FakeEffect.prototype, 'dispose')
235
+ const composerRef = React.createRef<EffectComposerImpl>()
236
+
237
+ try {
238
+ await React.act(async () =>
239
+ root.render(
240
+ <EffectComposer ref={composerRef}>
241
+ <FakeEffectComponent value={1} />
242
+ </EffectComposer>
243
+ )
244
+ )
245
+ await flush()
246
+
247
+ await React.act(async () => root.render(null))
248
+
249
+ expect(disposeSpy).toHaveBeenCalledTimes(1)
250
+ } finally {
251
+ disposeSpy.mockRestore()
252
+ }
253
+ })
254
+
255
+ it('forwards a callback ref\'s own returned cleanup (React 19 ref cleanup), instead of dropping it', async () => {
256
+ const composerRef = React.createRef<EffectComposerImpl>()
257
+ const events: string[] = []
258
+ const cleanup = vi.fn(() => {
259
+ events.push('cleanup')
260
+ })
261
+ const callbackRef = vi.fn((instance: FakeEffect | null) => {
262
+ events.push(instance ? 'attach' : 'attach-null')
263
+ if (instance) return cleanup
264
+ })
265
+
266
+ await React.act(async () =>
267
+ root.render(
268
+ <EffectComposer ref={composerRef}>
269
+ <FakeEffectComponent ref={callbackRef} value={1} />
270
+ </EffectComposer>
271
+ )
272
+ )
273
+ await flush()
274
+
275
+ expect(callbackRef).toHaveBeenCalledTimes(1)
276
+ expect(cleanup).not.toHaveBeenCalled()
277
+
278
+ await React.act(async () => root.render(null))
279
+
280
+ // React 19 ref-cleanup semantics: once a cleanup is returned, it's
281
+ // called directly - the callback itself is never re-invoked with null.
282
+ expect(cleanup).toHaveBeenCalledTimes(1)
283
+ expect(callbackRef).toHaveBeenCalledTimes(1)
284
+ expect(events).toEqual(['attach', 'cleanup'])
285
+ })
286
+ })