@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,238 @@
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 or src/passes must appear either in SMOKE_CASES or EXCLUDED
14
+ // below. Adding a new effect/pass 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 { DepthPickingPass as DepthPickingPassImpl, 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 { Noise } from '../effects/Noise'
48
+ import { Outline } from '../effects/Outline'
49
+ import { Pixelation } from '../effects/Pixelation'
50
+ import { Ramp } from '../effects/Ramp'
51
+ import { Scanline } from '../effects/ScanlineEffect'
52
+ import { SelectiveBloom } from '../effects/SelectiveBloom'
53
+ import { Sepia } from '../effects/Sepia'
54
+ import { ShockWave } from '../effects/ShockWave'
55
+ import { SMAA } from '../effects/SMAA'
56
+ import { SSAO } from '../effects/SSAO'
57
+ import { TiltShift } from '../effects/TiltShift'
58
+ import { TiltShift2 } from '../effects/TiltShift2'
59
+ import { ToneMapping } from '../effects/ToneMapping'
60
+ import { Vignette } from '../effects/Vignette'
61
+ import { WaterEffect } from '../effects/Water'
62
+ import { DepthPicking } from '../passes/DepthPicking'
63
+ import { N8AO } from '../passes/N8AO'
64
+ import { flush, root } from './test-utils'
65
+
66
+ type SmokeCase = {
67
+ /** Filename under src/effects or src/passes this case covers — drives the coverage check below. */
68
+ file: string
69
+ label: string
70
+ composerProps?: Record<string, unknown>
71
+ /** Extra scene content the effect needs (e.g. a sun mesh for GodRays). */
72
+ extras?: React.ReactNode
73
+ /** Renders the effect element. */
74
+ effect: (ref: React.Ref<any>) => React.ReactElement
75
+ }
76
+
77
+ const sunMesh = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 8))
78
+ const lutTexture = new THREE.DataTexture(new Uint8Array(4 * 4 * 4 * 4), 4, 4)
79
+
80
+ const SMOKE_CASES: SmokeCase[] = [
81
+ { file: 'Autofocus.tsx', label: 'Autofocus', effect: (ref) => <Autofocus ref={ref} /> },
82
+ { file: 'Bloom.tsx', label: 'Bloom', effect: (ref) => <Bloom ref={ref} /> },
83
+ { file: 'BrightnessContrast.tsx', label: 'BrightnessContrast', effect: (ref) => <BrightnessContrast ref={ref} /> },
84
+ { file: 'ChromaticAberration.tsx', label: 'ChromaticAberration', effect: (ref) => <ChromaticAberration ref={ref} /> },
85
+ { file: 'ColorAverage.tsx', label: 'ColorAverage', effect: (ref) => <ColorAverage ref={ref} /> },
86
+ { file: 'ColorDepth.tsx', label: 'ColorDepth', effect: (ref) => <ColorDepth ref={ref} /> },
87
+ { file: 'Depth.tsx', label: 'Depth', effect: (ref) => <Depth ref={ref} /> },
88
+ { file: 'DepthOfField.tsx', label: 'DepthOfField', effect: (ref) => <DepthOfField ref={ref} /> },
89
+ { file: 'DepthPicking.tsx', label: 'DepthPicking', effect: (ref) => <DepthPicking ref={ref} /> },
90
+ { file: 'DotScreen.tsx', label: 'DotScreen', effect: (ref) => <DotScreen ref={ref} /> },
91
+ { file: 'FXAA.tsx', label: 'FXAA', effect: (ref) => <FXAA ref={ref} /> },
92
+ { file: 'Glitch.tsx', label: 'Glitch', effect: (ref) => <Glitch ref={ref} /> },
93
+ {
94
+ file: 'GodRays.tsx',
95
+ label: 'GodRays',
96
+ extras: <primitive object={sunMesh} />,
97
+ effect: (ref) => <GodRays ref={ref} sun={sunMesh} />,
98
+ },
99
+ { file: 'Grid.tsx', label: 'Grid', effect: (ref) => <Grid ref={ref} /> },
100
+ { file: 'HueSaturation.tsx', label: 'HueSaturation', effect: (ref) => <HueSaturation ref={ref} /> },
101
+ { file: 'LensFlare.tsx', label: 'LensFlare', effect: (ref) => <LensFlare ref={ref} /> },
102
+ { file: 'LUT.tsx', label: 'LUT', effect: (ref) => <LUT ref={ref} lut={lutTexture} /> },
103
+ { file: 'N8AO.tsx', label: 'N8AO', effect: (ref) => <N8AO ref={ref} /> },
104
+ { file: 'Noise.tsx', label: 'Noise', effect: (ref) => <Noise ref={ref} /> },
105
+ { file: 'Outline.tsx', label: 'Outline', effect: (ref) => <Outline ref={ref} /> },
106
+ { file: 'Pixelation.tsx', label: 'Pixelation', effect: (ref) => <Pixelation ref={ref} /> },
107
+ { file: 'Ramp.tsx', label: 'Ramp', effect: (ref) => <Ramp ref={ref} /> },
108
+ { file: 'ScanlineEffect.tsx', label: 'Scanline', effect: (ref) => <Scanline ref={ref} /> },
109
+ {
110
+ file: 'SelectiveBloom.tsx',
111
+ label: 'SelectiveBloom',
112
+ effect: (ref) => <SelectiveBloom ref={ref} lights={[]} />,
113
+ },
114
+ { file: 'Sepia.tsx', label: 'Sepia', effect: (ref) => <Sepia ref={ref} /> },
115
+ { file: 'ShockWave.tsx', label: 'ShockWave', effect: (ref) => <ShockWave ref={ref} /> },
116
+ { file: 'SMAA.tsx', label: 'SMAA', effect: (ref) => <SMAA ref={ref} /> },
117
+ {
118
+ file: 'SSAO.tsx',
119
+ label: 'SSAO (without normal pass — sentinel path)',
120
+ effect: (ref) => <SSAO ref={ref} />,
121
+ },
122
+ {
123
+ file: 'SSAO.tsx',
124
+ label: 'SSAO (with normal pass — real construction path)',
125
+ composerProps: { enableNormalPass: true },
126
+ effect: (ref) => <SSAO ref={ref} />,
127
+ },
128
+ { file: 'TiltShift.tsx', label: 'TiltShift', effect: (ref) => <TiltShift ref={ref} /> },
129
+ { file: 'TiltShift2.tsx', label: 'TiltShift2', effect: (ref) => <TiltShift2 ref={ref} /> },
130
+ { file: 'ToneMapping.tsx', label: 'ToneMapping', effect: (ref) => <ToneMapping ref={ref} /> },
131
+ { file: 'Vignette.tsx', label: 'Vignette', effect: (ref) => <Vignette ref={ref} /> },
132
+ { file: 'Water.tsx', label: 'WaterEffect', effect: (ref) => <WaterEffect ref={ref} /> },
133
+ ]
134
+
135
+ const EXCLUDED: Record<string, string> = {
136
+ 'Texture.tsx':
137
+ 'loads via useLoader(TextureLoader, textureSrc) — needs a real image decode pipeline this test environment cannot provide. Verify manually.',
138
+ 'ASCII.tsx':
139
+ "constructs its character atlas via document.createElement('canvas') — this project's vitest config runs the node test environment (no jsdom/document). Verify manually.",
140
+ }
141
+
142
+ describe('effect smoke tests', () => {
143
+ it.each(SMOKE_CASES)('$label mounts and unmounts without throwing', async ({ composerProps, extras, effect }) => {
144
+ const ref = React.createRef<any>()
145
+ const composerRef = React.createRef<EffectComposerImpl>()
146
+
147
+ await React.act(async () =>
148
+ root.render(
149
+ <EffectComposer ref={composerRef} {...composerProps}>
150
+ {extras}
151
+ {effect(ref)}
152
+ </EffectComposer>
153
+ )
154
+ )
155
+
156
+ await flush()
157
+
158
+ const instance = ref.current
159
+ const disposeSpy = instance && typeof instance.dispose === 'function' ? vi.spyOn(instance, 'dispose') : null
160
+
161
+ await React.act(async () => root.render(null))
162
+ await flush()
163
+
164
+ if (disposeSpy) {
165
+ expect(disposeSpy).toHaveBeenCalledTimes(1)
166
+ }
167
+ })
168
+
169
+ // Autofocus's ref resolves to { dofRef, hitpoint, update }, not an effect
170
+ // instance - the generic dispose check above no-ops for it. It owns two
171
+ // disposables (the nested DepthPicking component's pass, and the
172
+ // nested DepthOfField effect), verified here.
173
+ it('Autofocus disposes the nested DepthPicking and DepthOfField effect', async () => {
174
+ const depthPickingDisposeSpy = vi.spyOn(DepthPickingPassImpl.prototype, 'dispose')
175
+ // AutofocusProps' `ref` type is broken (ComponentProps<typeof DepthOfField>
176
+ // drags in DepthOfField's own `ref: Ref<DepthOfFieldEffect>`, which then
177
+ // intersects with `Ref<AutofocusApi>` — separate pre-existing issue,
178
+ // not fixed here). `any` sidesteps it; the runtime shape is AutofocusApi.
179
+ const ref = React.createRef<any>()
180
+
181
+ await React.act(async () =>
182
+ root.render(
183
+ <EffectComposer>
184
+ <Autofocus ref={ref} />
185
+ </EffectComposer>
186
+ )
187
+ )
188
+
189
+ await flush()
190
+
191
+ const dofEffect = ref.current!.dofRef.current
192
+ expect(dofEffect).toBeTruthy()
193
+ const dofDisposeSpy = vi.spyOn(dofEffect!, 'dispose')
194
+
195
+ await React.act(async () => root.render(null))
196
+ await flush()
197
+
198
+ expect(depthPickingDisposeSpy).toHaveBeenCalled()
199
+ expect(dofDisposeSpy).toHaveBeenCalled()
200
+
201
+ depthPickingDisposeSpy.mockRestore()
202
+ })
203
+
204
+ it('DepthPicking disposes its pass on unmount', async () => {
205
+ const disposeSpy = vi.spyOn(DepthPickingPassImpl.prototype, 'dispose')
206
+ const ref = React.createRef<import('../passes/DepthPicking').DepthPickingApi>()
207
+
208
+ await React.act(async () =>
209
+ root.render(
210
+ <EffectComposer>
211
+ <DepthPicking ref={ref} />
212
+ </EffectComposer>
213
+ )
214
+ )
215
+
216
+ await flush()
217
+ expect(ref.current).toBeTruthy()
218
+
219
+ await React.act(async () => root.render(null))
220
+ await flush()
221
+
222
+ expect(disposeSpy).toHaveBeenCalled()
223
+
224
+ disposeSpy.mockRestore()
225
+ })
226
+
227
+ it('covers every file in src/effects and src/passes (or documents why it is excluded)', () => {
228
+ const srcDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
229
+ const files = ['effects', 'passes'].flatMap((dir) =>
230
+ fs.readdirSync(path.join(srcDir, dir)).filter((f) => f.endsWith('.tsx'))
231
+ )
232
+
233
+ const covered = new Set(SMOKE_CASES.map((c) => c.file))
234
+ const missing = files.filter((f) => !covered.has(f) && !(f in EXCLUDED))
235
+
236
+ expect(missing).toEqual([])
237
+ })
238
+ })
@@ -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
+ })