@react-three/postprocessing 3.0.3 → 3.0.5

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 (93) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +81 -81
  3. package/dist/EffectComposer.d.ts +28 -0
  4. package/dist/Selection.d.ts +17 -0
  5. package/dist/effects/ASCII.d.ts +19 -0
  6. package/dist/effects/Autofocus.d.ts +23 -0
  7. package/dist/effects/Bloom.d.ts +2 -0
  8. package/dist/effects/BrightnessContrast.d.ts +2 -0
  9. package/dist/effects/ChromaticAberration.d.ts +8 -0
  10. package/dist/effects/ColorAverage.d.ts +7 -0
  11. package/dist/effects/ColorDepth.d.ts +2 -0
  12. package/dist/effects/Depth.d.ts +2 -0
  13. package/dist/effects/DepthOfField.d.ts +14 -0
  14. package/dist/effects/DotScreen.d.ts +2 -0
  15. package/dist/effects/FXAA.d.ts +2 -0
  16. package/dist/effects/Glitch.d.ts +13 -0
  17. package/dist/effects/GodRays.d.ts +9 -0
  18. package/dist/effects/Grid.d.ts +11 -0
  19. package/dist/effects/HueSaturation.d.ts +2 -0
  20. package/dist/effects/LUT.d.ts +10 -0
  21. package/dist/effects/LensFlare.d.ts +54 -0
  22. package/dist/effects/N8AO.d.ts +19 -0
  23. package/dist/effects/Noise.d.ts +2 -0
  24. package/dist/effects/Outline.d.ts +11 -0
  25. package/dist/effects/Pixelation.d.ts +7 -0
  26. package/dist/effects/Ramp.d.ts +74 -0
  27. package/dist/effects/SMAA.d.ts +2 -0
  28. package/dist/effects/SSAO.d.ts +7 -0
  29. package/dist/effects/ScanlineEffect.d.ts +2 -0
  30. package/dist/effects/SelectiveBloom.d.ts +15 -0
  31. package/dist/effects/Sepia.d.ts +2 -0
  32. package/dist/effects/ShockWave.d.ts +2 -0
  33. package/dist/effects/Texture.d.ts +10 -0
  34. package/dist/effects/TiltShift.d.ts +2 -0
  35. package/dist/effects/TiltShift2.d.ts +18 -0
  36. package/dist/effects/ToneMapping.d.ts +4 -0
  37. package/dist/effects/Vignette.d.ts +2 -0
  38. package/dist/effects/Water.d.ts +8 -0
  39. package/dist/index.d.ts +38 -1
  40. package/dist/index.js +855 -551
  41. package/dist/index.js.map +1 -1
  42. package/dist/tests/test-utils.d.ts +12 -0
  43. package/dist/util.d.ts +25 -0
  44. package/dist/wrapEffect.d.ts +12 -0
  45. package/package.json +73 -66
  46. package/src/EffectComposer.tsx +278 -200
  47. package/src/Selection.tsx +86 -46
  48. package/src/effects/ASCII.tsx +136 -135
  49. package/src/effects/Autofocus.tsx +175 -153
  50. package/src/effects/Bloom.tsx +6 -6
  51. package/src/effects/BrightnessContrast.tsx +4 -4
  52. package/src/effects/ChromaticAberration.tsx +30 -5
  53. package/src/effects/ColorAverage.tsx +17 -15
  54. package/src/effects/ColorDepth.tsx +4 -4
  55. package/src/effects/Depth.tsx +4 -4
  56. package/src/effects/DepthOfField.tsx +87 -89
  57. package/src/effects/DotScreen.tsx +4 -4
  58. package/src/effects/FXAA.tsx +4 -4
  59. package/src/effects/Glitch.tsx +37 -40
  60. package/src/effects/GodRays.tsx +20 -16
  61. package/src/effects/Grid.tsx +28 -21
  62. package/src/effects/HueSaturation.tsx +4 -4
  63. package/src/effects/LUT.tsx +27 -26
  64. package/src/effects/LensFlare.tsx +611 -611
  65. package/src/effects/N8AO.tsx +81 -81
  66. package/src/effects/Noise.tsx +4 -4
  67. package/src/effects/Outline.tsx +85 -117
  68. package/src/effects/Pixelation.tsx +17 -15
  69. package/src/effects/Ramp.tsx +150 -150
  70. package/src/effects/SMAA.tsx +4 -4
  71. package/src/effects/SSAO.tsx +44 -41
  72. package/src/effects/ScanlineEffect.tsx +7 -7
  73. package/src/effects/SelectiveBloom.tsx +116 -126
  74. package/src/effects/Sepia.tsx +4 -4
  75. package/src/effects/ShockWave.tsx +4 -4
  76. package/src/effects/Texture.tsx +27 -23
  77. package/src/effects/TiltShift.tsx +4 -4
  78. package/src/effects/TiltShift2.tsx +90 -90
  79. package/src/effects/ToneMapping.tsx +6 -6
  80. package/src/effects/Vignette.tsx +4 -4
  81. package/src/effects/Water.tsx +35 -35
  82. package/src/index.ts +41 -40
  83. package/src/tests/ChromaticAberration.test.tsx +31 -0
  84. package/src/tests/EffectComposer.test.tsx +911 -0
  85. package/src/tests/Outline.test.tsx +89 -0
  86. package/src/tests/Selection.test.tsx +324 -0
  87. package/src/tests/SelectiveBloom.test.tsx +118 -0
  88. package/src/tests/effects.smoke.test.tsx +234 -0
  89. package/src/tests/test-utils.tsx +96 -0
  90. package/src/tests/wrapEffect.test.tsx +209 -0
  91. package/src/util.tsx +96 -55
  92. package/src/wrapEffect.tsx +122 -0
  93. package/src/EffectComposer.test.tsx +0 -126
@@ -0,0 +1,234 @@
1
+ // Generic smoke coverage for every effect component: mounts inside a real
2
+ // EffectComposer with the minimum props each one actually requires, confirms
3
+ // mounting/unmounting doesn't throw, and that dispose() gets called exactly
4
+ // once per instance on unmount (for both <primitive>-based effects, via our
5
+ // useDispose hook, and wrapEffect-based ones, via r3f's own auto-dispose).
6
+ //
7
+ // This catches constructor/prop-application/dispose-time crashes — it does
8
+ // NOT verify visual/shader correctness. The test environment's WebGL context
9
+ // is a Proxy of no-ops (see test-utils.tsx), so nothing actually renders;
10
+ // effects still need manual/visual verification before release.
11
+ //
12
+ // Coverage is enforced by the last test in this file: every *.tsx file in
13
+ // src/effects must appear either in SMOKE_CASES or EXCLUDED below. Adding a
14
+ // new effect file without touching either list fails CI.
15
+ //
16
+ // This file is excluded from `tsc -p tsconfig.json` (it matches
17
+ // src/**/*.test.*), so editors fall back to a detached/inferred compilation
18
+ // context for it that doesn't automatically pick up @types/node — hence the
19
+ // explicit reference below for the node: imports used by the coverage check.
20
+
21
+ /// <reference types="node" />
22
+
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { CopyPass, DepthPickingPass, EffectComposer as EffectComposerImpl } from 'postprocessing'
27
+ import * as React from 'react'
28
+ import * as THREE from 'three'
29
+ import { describe, expect, it, vi } from 'vitest'
30
+ import { EffectComposer } from '../EffectComposer'
31
+ import { Autofocus } from '../effects/Autofocus'
32
+ import { Bloom } from '../effects/Bloom'
33
+ import { BrightnessContrast } from '../effects/BrightnessContrast'
34
+ import { ChromaticAberration } from '../effects/ChromaticAberration'
35
+ import { ColorAverage } from '../effects/ColorAverage'
36
+ import { ColorDepth } from '../effects/ColorDepth'
37
+ import { Depth } from '../effects/Depth'
38
+ import { DepthOfField } from '../effects/DepthOfField'
39
+ import { DotScreen } from '../effects/DotScreen'
40
+ import { FXAA } from '../effects/FXAA'
41
+ import { Glitch } from '../effects/Glitch'
42
+ import { GodRays } from '../effects/GodRays'
43
+ import { Grid } from '../effects/Grid'
44
+ import { HueSaturation } from '../effects/HueSaturation'
45
+ import { LensFlare } from '../effects/LensFlare'
46
+ import { LUT } from '../effects/LUT'
47
+ import { N8AO } from '../effects/N8AO'
48
+ import { Noise } from '../effects/Noise'
49
+ import { Outline } from '../effects/Outline'
50
+ import { Pixelation } from '../effects/Pixelation'
51
+ import { Ramp } from '../effects/Ramp'
52
+ import { Scanline } from '../effects/ScanlineEffect'
53
+ import { SelectiveBloom } from '../effects/SelectiveBloom'
54
+ import { Sepia } from '../effects/Sepia'
55
+ import { ShockWave } from '../effects/ShockWave'
56
+ import { SMAA } from '../effects/SMAA'
57
+ import { SSAO } from '../effects/SSAO'
58
+ import { TiltShift } from '../effects/TiltShift'
59
+ import { TiltShift2 } from '../effects/TiltShift2'
60
+ import { ToneMapping } from '../effects/ToneMapping'
61
+ import { Vignette } from '../effects/Vignette'
62
+ import { WaterEffect } from '../effects/Water'
63
+ import { flush, root } from './test-utils'
64
+
65
+ type SmokeCase = {
66
+ /** Filename under src/effects this case covers — drives the coverage check below. */
67
+ file: string
68
+ label: string
69
+ composerProps?: Record<string, unknown>
70
+ /** Extra scene content the effect needs (e.g. a sun mesh for GodRays). */
71
+ extras?: React.ReactNode
72
+ /** Renders the effect element. `ref` may be ignored by effects that don't forward one (e.g. LensFlare). */
73
+ effect: (ref: React.Ref<any>) => React.ReactElement
74
+ }
75
+
76
+ const sunMesh = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 8))
77
+ const lutTexture = new THREE.DataTexture(new Uint8Array(4 * 4 * 4 * 4), 4, 4)
78
+
79
+ const SMOKE_CASES: SmokeCase[] = [
80
+ { file: 'Autofocus.tsx', label: 'Autofocus', effect: (ref) => <Autofocus ref={ref} /> },
81
+ { file: 'Bloom.tsx', label: 'Bloom', effect: (ref) => <Bloom ref={ref} /> },
82
+ { file: 'BrightnessContrast.tsx', label: 'BrightnessContrast', effect: (ref) => <BrightnessContrast ref={ref} /> },
83
+ { file: 'ChromaticAberration.tsx', label: 'ChromaticAberration', effect: (ref) => <ChromaticAberration ref={ref} /> },
84
+ { file: 'ColorAverage.tsx', label: 'ColorAverage', effect: (ref) => <ColorAverage ref={ref} /> },
85
+ { file: 'ColorDepth.tsx', label: 'ColorDepth', effect: (ref) => <ColorDepth ref={ref} /> },
86
+ { file: 'Depth.tsx', label: 'Depth', effect: (ref) => <Depth ref={ref} /> },
87
+ { file: 'DepthOfField.tsx', label: 'DepthOfField', effect: (ref) => <DepthOfField ref={ref} /> },
88
+ { file: 'DotScreen.tsx', label: 'DotScreen', effect: (ref) => <DotScreen ref={ref} /> },
89
+ { file: 'FXAA.tsx', label: 'FXAA', effect: (ref) => <FXAA ref={ref} /> },
90
+ { file: 'Glitch.tsx', label: 'Glitch', effect: (ref) => <Glitch ref={ref} /> },
91
+ {
92
+ file: 'GodRays.tsx',
93
+ label: 'GodRays',
94
+ extras: <primitive object={sunMesh} />,
95
+ effect: (ref) => <GodRays ref={ref} sun={sunMesh} />,
96
+ },
97
+ { file: 'Grid.tsx', label: 'Grid', effect: (ref) => <Grid ref={ref} /> },
98
+ { file: 'HueSaturation.tsx', label: 'HueSaturation', effect: (ref) => <HueSaturation ref={ref} /> },
99
+ // LensFlare manages its own internal ref and doesn't accept one as a prop.
100
+ { file: 'LensFlare.tsx', label: 'LensFlare', effect: () => <LensFlare /> },
101
+ { file: 'LUT.tsx', label: 'LUT', effect: (ref) => <LUT ref={ref} lut={lutTexture} /> },
102
+ { file: 'N8AO.tsx', label: 'N8AO', effect: (ref) => <N8AO ref={ref} /> },
103
+ { file: 'Noise.tsx', label: 'Noise', effect: (ref) => <Noise ref={ref} /> },
104
+ { file: 'Outline.tsx', label: 'Outline', effect: (ref) => <Outline ref={ref} /> },
105
+ { file: 'Pixelation.tsx', label: 'Pixelation', effect: (ref) => <Pixelation ref={ref} /> },
106
+ { file: 'Ramp.tsx', label: 'Ramp', effect: (ref) => <Ramp ref={ref} /> },
107
+ { file: 'ScanlineEffect.tsx', label: 'Scanline', effect: (ref) => <Scanline ref={ref} /> },
108
+ {
109
+ file: 'SelectiveBloom.tsx',
110
+ label: 'SelectiveBloom',
111
+ effect: (ref) => <SelectiveBloom ref={ref} lights={[]} />,
112
+ },
113
+ { file: 'Sepia.tsx', label: 'Sepia', effect: (ref) => <Sepia ref={ref} /> },
114
+ { file: 'ShockWave.tsx', label: 'ShockWave', effect: (ref) => <ShockWave ref={ref} /> },
115
+ { file: 'SMAA.tsx', label: 'SMAA', effect: (ref) => <SMAA ref={ref} /> },
116
+ {
117
+ file: 'SSAO.tsx',
118
+ label: 'SSAO (without normal pass — sentinel path)',
119
+ effect: (ref) => <SSAO ref={ref} />,
120
+ },
121
+ {
122
+ file: 'SSAO.tsx',
123
+ label: 'SSAO (with normal pass — real construction path)',
124
+ composerProps: { enableNormalPass: true },
125
+ effect: (ref) => <SSAO ref={ref} />,
126
+ },
127
+ { file: 'TiltShift.tsx', label: 'TiltShift', effect: (ref) => <TiltShift ref={ref} /> },
128
+ { file: 'TiltShift2.tsx', label: 'TiltShift2', effect: (ref) => <TiltShift2 ref={ref} /> },
129
+ { file: 'ToneMapping.tsx', label: 'ToneMapping', effect: (ref) => <ToneMapping ref={ref} /> },
130
+ { file: 'Vignette.tsx', label: 'Vignette', effect: (ref) => <Vignette ref={ref} /> },
131
+ { file: 'Water.tsx', label: 'WaterEffect', effect: (ref) => <WaterEffect ref={ref} /> },
132
+ ]
133
+
134
+ const EXCLUDED: Record<string, string> = {
135
+ 'Texture.tsx':
136
+ 'loads via useLoader(TextureLoader, textureSrc) — needs a real image decode pipeline this test environment cannot provide. Verify manually.',
137
+ 'ASCII.tsx':
138
+ "constructs its character atlas via document.createElement('canvas') — this project's vitest config runs the node test environment (no jsdom/document). Verify manually.",
139
+ }
140
+
141
+ describe('effect smoke tests', () => {
142
+ it.each(SMOKE_CASES)('$label mounts and unmounts without throwing', async ({ composerProps, extras, effect }) => {
143
+ const ref = React.createRef<any>()
144
+ const composerRef = React.createRef<EffectComposerImpl>()
145
+
146
+ await React.act(async () =>
147
+ root.render(
148
+ <EffectComposer ref={composerRef} {...composerProps}>
149
+ {extras}
150
+ {effect(ref)}
151
+ </EffectComposer>
152
+ )
153
+ )
154
+
155
+ await flush()
156
+
157
+ const instance = ref.current
158
+ const disposeSpy = instance && typeof instance.dispose === 'function' ? vi.spyOn(instance, 'dispose') : null
159
+
160
+ await React.act(async () => root.render(null))
161
+ await flush()
162
+
163
+ if (disposeSpy) {
164
+ expect(disposeSpy).toHaveBeenCalledTimes(1)
165
+ }
166
+ })
167
+
168
+ // Tracks dispose() calls per instance rather than per class — EffectComposerImpl
169
+ // constructs its own internal CopyPass (this.copyPass, for compositing) and
170
+ // disposes it as part of its own teardown, unrelated to any CopyPass an effect
171
+ // constructs. A class-wide spy would conflate the two into a false "double
172
+ // dispose"; this only flags it if the *same* instance is disposed twice.
173
+ function trackDisposePerInstance(Ctor: { prototype: { dispose: (...args: unknown[]) => unknown } }) {
174
+ const counts = new Map<object, number>()
175
+ const original = Ctor.prototype.dispose
176
+ const spy = vi.spyOn(Ctor.prototype, 'dispose').mockImplementation(function (this: object, ...args: unknown[]) {
177
+ counts.set(this, (counts.get(this) ?? 0) + 1)
178
+ return original.apply(this, args)
179
+ })
180
+ return {
181
+ restore: () => spy.mockRestore(),
182
+ maxCallsForAnySingleInstance: () => Math.max(0, ...counts.values()),
183
+ }
184
+ }
185
+
186
+ // Autofocus's ref resolves to { dofRef, hitpoint, update } (its own
187
+ // imperative API), not an effect instance — the generic dispose check
188
+ // above silently no-ops for it. It actually owns three disposables
189
+ // (depthPickingPass, copyPass, and the DepthOfField effect it renders
190
+ // internally), verified explicitly here instead.
191
+ it('Autofocus disposes depthPickingPass, copyPass, and the nested DepthOfField effect exactly once each', async () => {
192
+ const depthPickingTracker = trackDisposePerInstance(DepthPickingPass)
193
+ const copyPassTracker = trackDisposePerInstance(CopyPass)
194
+ // AutofocusProps' `ref` type is broken (ComponentProps<typeof DepthOfField>
195
+ // drags in DepthOfField's own `ref: Ref<DepthOfFieldEffect>`, which then
196
+ // intersects with `Ref<AutofocusApi>` — separate pre-existing issue,
197
+ // not fixed here). `any` sidesteps it; the runtime shape is AutofocusApi.
198
+ const ref = React.createRef<any>()
199
+
200
+ await React.act(async () =>
201
+ root.render(
202
+ <EffectComposer>
203
+ <Autofocus ref={ref} />
204
+ </EffectComposer>
205
+ )
206
+ )
207
+
208
+ await flush()
209
+
210
+ const dofEffect = ref.current!.dofRef.current
211
+ expect(dofEffect).toBeTruthy()
212
+ const dofDisposeSpy = vi.spyOn(dofEffect!, 'dispose')
213
+
214
+ await React.act(async () => root.render(null))
215
+ await flush()
216
+
217
+ expect(depthPickingTracker.maxCallsForAnySingleInstance()).toBeLessThanOrEqual(1)
218
+ expect(copyPassTracker.maxCallsForAnySingleInstance()).toBeLessThanOrEqual(1)
219
+ expect(dofDisposeSpy).toHaveBeenCalledTimes(1)
220
+
221
+ depthPickingTracker.restore()
222
+ copyPassTracker.restore()
223
+ })
224
+
225
+ it('covers every file in src/effects (or documents why it is excluded)', () => {
226
+ const effectsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'effects')
227
+ const files = fs.readdirSync(effectsDir).filter((f) => f.endsWith('.tsx'))
228
+
229
+ const covered = new Set(SMOKE_CASES.map((c) => c.file))
230
+ const missing = files.filter((f) => !covered.has(f) && !(f in EXCLUDED))
231
+
232
+ expect(missing).toEqual([])
233
+ })
234
+ })
@@ -0,0 +1,96 @@
1
+ import { createRoot, extend } from '@react-three/fiber'
2
+ import { EffectComposer as EffectComposerImpl, EffectPass } from 'postprocessing'
3
+ import * as React from 'react'
4
+ import * as THREE from 'three'
5
+
6
+ declare global {
7
+ var IS_REACT_ACT_ENVIRONMENT: boolean
8
+ }
9
+
10
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
11
+
12
+ extend(THREE as any)
13
+
14
+ export const root = createRoot({
15
+ style: {} as CSSStyleDeclaration,
16
+ addEventListener: (() => {}) as any,
17
+ removeEventListener: (() => {}) as any,
18
+ width: 1280,
19
+ height: 800,
20
+ clientWidth: 1280,
21
+ clientHeight: 800,
22
+ getContext: (() =>
23
+ new Proxy(
24
+ {},
25
+ {
26
+ get(_target, prop) {
27
+ switch (prop) {
28
+ case 'getParameter':
29
+ return () => 'WebGL 2'
30
+ case 'getExtension':
31
+ return () => ({})
32
+ case 'getContextAttributes':
33
+ return () => ({ alpha: true })
34
+ case 'getShaderPrecisionFormat':
35
+ return () => ({ rangeMin: 1, rangeMax: 1, precision: 1 })
36
+ default:
37
+ return () => {}
38
+ }
39
+ },
40
+ }
41
+ )) as any,
42
+ } satisfies Partial<HTMLCanvasElement> as HTMLCanvasElement)
43
+
44
+ root.configure({ frameloop: 'never' })
45
+
46
+ export const EFFECT_SHADER = `
47
+ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
48
+ outputColor = inputColor;
49
+ }
50
+ `
51
+
52
+ export const flush = async () => {
53
+ await React.act(async () => {
54
+ await Promise.resolve()
55
+ })
56
+ }
57
+
58
+ export const strict = (children: React.ReactNode) => <React.StrictMode>{children}</React.StrictMode>
59
+
60
+ export const waitForComposer = async (ref: React.RefObject<EffectComposerImpl | null>): Promise<EffectComposerImpl> => {
61
+ for (let i = 0; i < 50; i++) {
62
+ await flush()
63
+ if (ref.current) return ref.current
64
+ }
65
+ throw new Error('Composer was not created')
66
+ }
67
+
68
+ export const waitForNewComposer = async (
69
+ ref: React.RefObject<EffectComposerImpl | null>,
70
+ previous: EffectComposerImpl
71
+ ): Promise<EffectComposerImpl> => {
72
+ for (let i = 0; i < 50; i++) {
73
+ await flush()
74
+ if (ref.current && ref.current !== previous) return ref.current
75
+ }
76
+ throw new Error('Composer was not recreated')
77
+ }
78
+
79
+ export const waitForEffects = async (ref: React.RefObject<EffectComposerImpl | null>, count: number) => {
80
+ for (let i = 0; i < 50; i++) {
81
+ await flush()
82
+
83
+ const pass = ref.current?.passes.find(
84
+ (p): p is EffectPass =>
85
+ // @ts-expect-error - `effects` isn't part of the public Pass typing
86
+ p instanceof EffectPass && p.effects.length === count
87
+ )
88
+
89
+ if (pass) {
90
+ // @ts-expect-error
91
+ return pass.effects
92
+ }
93
+ }
94
+
95
+ throw new Error(`Expected EffectPass with ${count} effects`)
96
+ }
@@ -0,0 +1,209 @@
1
+ import { Effect, EffectComposer as EffectComposerImpl } from 'postprocessing'
2
+ import * as React from 'react'
3
+ import { DataTexture, Texture } from 'three'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { EffectComposer } from '../EffectComposer'
6
+ import { wrapEffect } from '../wrapEffect'
7
+ import { flush, root } from './test-utils'
8
+
9
+ class FakeEffect extends Effect {
10
+ value: number
11
+ constructor({ value = 0 }: { value?: number } = {}) {
12
+ super('FakeEffect', 'mainImage() {}')
13
+ this.value = value
14
+ }
15
+ }
16
+
17
+ const FakeEffectComponent = /* @__PURE__ */ wrapEffect(FakeEffect)
18
+
19
+ class CircularPropsEffect extends Effect {
20
+ constructor(_props: Record<string, unknown> = {}) {
21
+ super('CircularPropsEffect', 'mainImage() {}')
22
+ }
23
+ }
24
+
25
+ const CircularPropsComponent = /* @__PURE__ */ wrapEffect(CircularPropsEffect)
26
+
27
+ describe('wrapEffect', () => {
28
+ it('passes the constructed value through to the underlying effect instance', async () => {
29
+ const composerRef = React.createRef<EffectComposerImpl>()
30
+
31
+ await React.act(async () =>
32
+ root.render(
33
+ <EffectComposer ref={composerRef}>
34
+ <FakeEffectComponent value={42} />
35
+ </EffectComposer>
36
+ )
37
+ )
38
+
39
+ await flush()
40
+
41
+ // @ts-expect-error
42
+ const effect = composerRef.current!.passes[1].effects[0]
43
+
44
+ expect(effect).toBeInstanceOf(FakeEffect)
45
+ expect(effect.value).toBe(42)
46
+
47
+ await React.act(async () => root.render(null))
48
+ })
49
+
50
+ it('registers the new instance in the same act() as the args change, not one render later', async () => {
51
+ const composerRef = React.createRef<EffectComposerImpl>()
52
+
53
+ await React.act(async () =>
54
+ root.render(
55
+ <EffectComposer ref={composerRef}>
56
+ <FakeEffectComponent value={1} />
57
+ </EffectComposer>
58
+ )
59
+ )
60
+
61
+ await flush()
62
+
63
+ const firstEffect =
64
+ // @ts-expect-error
65
+ composerRef.current!.passes[1].effects[0]
66
+
67
+ expect(firstEffect.value).toBe(1)
68
+
69
+ await React.act(async () =>
70
+ root.render(
71
+ <EffectComposer ref={composerRef}>
72
+ <FakeEffectComponent value={2} />
73
+ </EffectComposer>
74
+ )
75
+ )
76
+
77
+ await flush()
78
+
79
+ const secondEffect =
80
+ // @ts-expect-error
81
+ composerRef.current!.passes[1].effects[0]
82
+
83
+ expect(secondEffect).not.toBe(firstEffect)
84
+ expect(secondEffect.value).toBe(2)
85
+
86
+ await React.act(async () => root.render(null))
87
+ })
88
+
89
+ it('does not crash when a prop value contains a circular reference (#333, #334)', async () => {
90
+ const composerRef = React.createRef<EffectComposerImpl>()
91
+ const circular: Record<string, unknown> = { value: 1 }
92
+ circular.self = circular
93
+
94
+ await React.act(async () =>
95
+ root.render(
96
+ <EffectComposer ref={composerRef}>
97
+ <CircularPropsComponent extra={circular} />
98
+ </EffectComposer>
99
+ )
100
+ )
101
+
102
+ await flush()
103
+
104
+ // @ts-expect-error
105
+ const effect = composerRef.current!.passes[1].effects[0]
106
+
107
+ expect(effect).toBeInstanceOf(CircularPropsEffect)
108
+
109
+ await React.act(async () => root.render(null))
110
+ })
111
+
112
+ it('fingerprints a texture prop by identity, without invoking its toJSON (perf)', async () => {
113
+ const toJSONSpy = vi.spyOn(Texture.prototype, 'toJSON')
114
+ const composerRef = React.createRef<EffectComposerImpl>()
115
+ const textureA = new DataTexture(new Uint8Array(4), 1, 1)
116
+
117
+ await React.act(async () =>
118
+ root.render(
119
+ <EffectComposer ref={composerRef}>
120
+ <CircularPropsComponent extra={textureA} />
121
+ </EffectComposer>
122
+ )
123
+ )
124
+
125
+ await flush()
126
+
127
+ const firstEffect =
128
+ // @ts-expect-error
129
+ composerRef.current!.passes[1].effects[0]
130
+
131
+ // same texture reference on re-render — should not recreate the instance
132
+ await React.act(async () =>
133
+ root.render(
134
+ <EffectComposer ref={composerRef}>
135
+ <CircularPropsComponent extra={textureA} />
136
+ </EffectComposer>
137
+ )
138
+ )
139
+
140
+ await flush()
141
+
142
+ const secondEffect =
143
+ // @ts-expect-error
144
+ composerRef.current!.passes[1].effects[0]
145
+
146
+ expect(secondEffect).toBe(firstEffect)
147
+
148
+ // a genuinely different texture instance — should recreate
149
+ const textureB = new DataTexture(new Uint8Array(4), 1, 1)
150
+
151
+ await React.act(async () =>
152
+ root.render(
153
+ <EffectComposer ref={composerRef}>
154
+ <CircularPropsComponent extra={textureB} />
155
+ </EffectComposer>
156
+ )
157
+ )
158
+
159
+ await flush()
160
+
161
+ const thirdEffect =
162
+ // @ts-expect-error
163
+ composerRef.current!.passes[1].effects[0]
164
+
165
+ expect(thirdEffect).not.toBe(secondEffect)
166
+
167
+ expect(toJSONSpy).not.toHaveBeenCalled()
168
+
169
+ await React.act(async () => root.render(null))
170
+ toJSONSpy.mockRestore()
171
+ })
172
+
173
+ it('does not crash on repeated re-renders once a ref is attached', async () => {
174
+ // Regression for a bug specific to the *old* wrapEffect: it never
175
+ // destructured `ref` out of props, so `ref` landed in `...props` and
176
+ // JSON.stringify(props) tried to serialize `ref.current` — the mounted
177
+ // effect instance itself — on every re-render after the first, which
178
+ // crashed the same way #333/#334 did for texture props. Current
179
+ // wrapEffect destructures `ref` explicitly, so it never reaches the
180
+ // fingerprint at all; this just locks that in.
181
+ const composerRef = React.createRef<EffectComposerImpl>()
182
+ const effectRef = React.createRef<FakeEffect>()
183
+
184
+ function Harness({ tick: _tick }: { tick: number }) {
185
+ return (
186
+ <EffectComposer ref={composerRef}>
187
+ <FakeEffectComponent ref={effectRef} value={1} />
188
+ </EffectComposer>
189
+ )
190
+ }
191
+
192
+ await React.act(async () => root.render(<Harness tick={0} />))
193
+ await flush()
194
+
195
+ expect(effectRef.current).toBeInstanceOf(FakeEffect)
196
+ const firstInstance = effectRef.current
197
+
198
+ for (let tick = 1; tick <= 5; tick++) {
199
+ await React.act(async () => root.render(<Harness tick={tick} />))
200
+ }
201
+
202
+ await flush()
203
+
204
+ expect(effectRef.current).toBe(firstInstance)
205
+ expect(effectRef.current).toBeInstanceOf(FakeEffect)
206
+
207
+ await React.act(async () => root.render(null))
208
+ })
209
+ })