Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/createEffectComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { extend, useThree } from '@react-three/fiber'
import type { BlendFunction, Effect, Pass } from 'postprocessing'
import type { ExoticComponent, JSX, Ref } from 'react'
import { useCallback, useRef } from 'react'
import { useLiveDefaults } from './util'

export type EffectConstructor = new (...args: any[]) => Effect | Pass

// The effect's own options type, straight off its constructor - postprocessing
// already types every effect's sole options object precisely (either inline or,
// like BloomEffect, as a named exported type); this just strips the `| undefined`
// that comes from the parameter being optional.
export type EffectOptions<T extends EffectConstructor> = NonNullable<ConstructorParameters<T>[0]>

const components = new WeakMap<EffectConstructor, ExoticComponent<any> | string>()
let i = 0

const BLEND_KEYS = ['blendMode-blendFunction', 'blendMode-opacity-value']

/**
* Registers `effect` as a JSX intrinsic once per class and returns a
* component that renders it. Everything else - construction from `args`,
* live prop application (with the same Color/Vector coercion and reset-
* to-default on removal any r3f element gets), disposal - is r3f's own
* reconciler, same rules as `<mesh>`/`<meshStandardMaterial>`. Only fits
* effects whose constructor works with zero arguments (`new Effect()`) -
* r3f's own reset-on-removal falls back to `0` otherwise, which is wrong
* for anything non-numeric. Effects that require e.g. scene/camera stay
* hand-rolled (see Outline.tsx, SelectiveBloom.tsx, ShockWave.tsx).
*
* `blendFunction`/`opacity` are pierced through to `blendMode-*` - every
* `Effect` has them on a nested `blendMode`, not on the effect itself, so a
* plain top-level prop would silently land on a stray, unread property.
* Applied via useLiveDefaults, not as plain JSX props: BlendMode's own
* constructor requires `blendFunction` (no default), so its constructor
* length isn't 0 either, and r3f's native reset-on-removal falls back to
* `changedProps[prop] = 0` - which is BlendFunction.SKIP, not a merely
* "wrong" blend function but one that hides the effect entirely.
*/
export function createEffectComponent<T extends EffectConstructor, P extends object>(
effect: T
): (
props: P & {
blendFunction?: BlendFunction
opacity?: number
args?: ConstructorParameters<T>
ref?: Ref<InstanceType<T>>
}
) => JSX.Element {
return function EffectComponent({ blendFunction, opacity, ref, ...props }: any) {
let Component = components.get(effect)

if (!Component) {
const key = `@react-three/postprocessing/${effect.name}-${i++}`
extend({ [key]: effect })
components.set(effect, (Component = key))
}

const camera = useThree((state) => state.camera)
const localRef = useRef<InstanceType<T>>(null)

// Forwards ref's own return value: r3f's setFiberRef (React 19-style ref
// cleanup) calls the ref function again only if it *didn't* return one,
// otherwise it stores and calls that instead - never re-invoking this
// function with null. So localRef must be cleared from inside that same
// returned cleanup, not left for a null call that will never come.
const setRef = useCallback(
(instance: InstanceType<T> | null) => {
localRef.current = instance
if (typeof ref !== 'function') {
if (ref) ref.current = instance
return
}
const cleanup = ref(instance)
if (typeof cleanup !== 'function') return
return () => {
localRef.current = null
cleanup()
}
},
[ref]
)

useLiveDefaults(
localRef,
{ 'blendMode-blendFunction': blendFunction, 'blendMode-opacity-value': opacity },
BLEND_KEYS
)

return <Component ref={setRef} camera={camera} {...props} />
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './createEffectComponent'
export * from './EffectComposer'
export * from './Selection'
export * from './util'
Expand Down
13 changes: 10 additions & 3 deletions src/tests/EffectComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -453,8 +453,9 @@ describe('EffectComposer', () => {
}
})

it('never disposes the same ColorAverage instance twice, even in StrictMode', async () => {
it('disposes every ColorAverage instance seen, even across StrictMode\'s mount/cleanup/mount cycle', async () => {
const disposedNodes: ColorAverageEffect[] = []
const seenInstances = new Set<ColorAverageEffect>()
const disposeSpy = vi.spyOn(ColorAverageEffect.prototype, 'dispose').mockImplementation(function (
this: ColorAverageEffect
) {
Expand All @@ -474,11 +475,17 @@ describe('EffectComposer', () => {
)
)
await flush()
if (ref.current) seenInstances.add(ref.current)
}
await React.act(async () => root.render(null))

const uniqueDisposed = new Set(disposedNodes)
expect(uniqueDisposed.size).toBe(disposedNodes.length)
// dispose() is idempotent (just event-firing / shallow property
// disposal, no internal state), so StrictMode calling it more than
// once per instance is fine - this only checks nothing leaked.
const disposedSet = new Set(disposedNodes)
for (const instance of seenInstances) {
expect(disposedSet.has(instance)).toBe(true)
}
} finally {
disposeSpy.mockRestore()
}
Expand Down
Loading