Skip to content

fix: composer leak, effect ordering, and several primitive/wrapEffect bugs - #350

Open
kvvasuu wants to merge 18 commits into
masterfrom
fix/composer-leaks-and-ordering
Open

fix: composer leak, effect ordering, and several primitive/wrapEffect bugs#350
kvvasuu wants to merge 18 commits into
masterfrom
fix/composer-leaks-and-ordering

Conversation

@kvvasuu

@kvvasuu kvvasuu commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

A set of independently-verified bugfixes on top of the existing architecture. No changes to the public component API.

Dependencies / build

  • postprocessing moved from a direct dependency to a peer dependency (^6.36.0).
  • @react-three/fiber peer range raised to >=9.5.0
  • three peer range raised to >=0.182.0.
  • Tooling only, no consumer-facing effect: eslint migrated to v9 flat config, TypeScript/vite/vitest updated.

Fixed

  • Fixes Memory leak when switching cameras #270 - composer leak when switching cameras/scene: EffectComposerImpl was created in useMemo, which doesn't guarantee its cleanup runs before React discards a memoized value. Now created/disposed via useState+useEffect. As a side effect, this surfaced a double-dispose in Autofocus (its own cleanup and the composer's new, correct teardown could both dispose the same passes) - guarded against that too.
  • Pass order: reordering effects in JSX while React preserves their identity (e.g. a keyed list) could leave a stale duplicate in the composer's pass list. Added a defensive dedupe when reading the render tree to recover the correct order regardless.
  • Fixes cyclic object value error in Scanline when using useRef #333, fixes circular structure to JSON --> starting at object with constructor #334 - wrapEffect crashed with "Converting circular structure to JSON" when a prop held an object with a back-reference (textures, or any ref - the old code never destructured ref out of props). Fixed the crash, and a follow-up perf issue in the same code path: the fix still triggered Texture.toJSON() (full image -> base64 re-encode) on every render - measured ~39ms for a 512x512 texture, now ~0.2ms.
  • Fixes ChromaticAberration: tuple offset bypasses Vector2 coercion, offset.set() throws #348 - ChromaticAberration's tuple offset wasn't coerced to a real Vector2, so .set() threw.
  • dispose() wired up across <primitive>-based effects that had none at all in master and leaked on every unmount/prop-driven recreation (ASCII, ColorAverage, ChromaticAberration, Grid, LUT, N8AO, Pixelation, SelectiveBloom, SSAO, Texture, GodRays), and consolidated onto the same StrictMode-safe hook for a few that already disposed on normal unmount but could double-dispose under StrictMode (DepthOfField, Glitch, Outline). Fixes SelectiveBloom allocates huge GPU memory even with empty selection #344
  • EffectComposer's children prop was typed as JSX.Element | JSX.Element[], rejecting condition && <Effect/> and forcing a cast. Widened to ReactNode.
  • wrapEffect's generated prop types resolved to effectively any in master (ConstructorParameters<T>[0] degrades badly for optional/no-arg constructors) - no autocomplete, no compile-time checks. EffectProps<T> now derives real per-effect types from each constructor's actual signature.

Tests

  • Regression coverage for every fix above (composer leak, pass ordering/dedupe, circular-prop crash, texture perf, offset coercion, double-dispose).
  • A parameterized smoke test mounts every effect component with minimal/required props inside a real EffectComposer, and checks it mounts, unmounts, and disposes without throwing.
  • A coverage test enumerates src/effects/*.tsx at run time and fails if a file isn't in the smoke-test manifest or an explicit, documented exclusion list (2 effects need a real browser - document/image decoding - so they're excluded with a reason rather than silently skipped) - so a newly added effect can't ship without at least this baseline check.

Test plan

  • yarn test - 63 tests
  • yarn typecheck / yarn eslint:ci clean
  • Manually verified against a live app (memory profiling for the leak/dispose fixes, visual checks for ordering/crash fixes)

kvvasuu added 17 commits July 26, 2026 21:22
Pulls wrapEffect out of the general util grab-bag into its own module,
and lets generated effect components accept ref as a regular prop
(React 19) instead of having no ref support at all — previously none
of the wrapEffect-based effects (Bloom, Vignette, HueSaturation, etc.)
could be given a ref.
…reorder bug

EffectComposerImpl was created inside useMemo, which doesn't guarantee
its cleanup runs before React discards a memoized value — this leaked
the composer (and its WebGL resources) under StrictMode. It's now
created and disposed inside a useState/useEffect lifecycle instead.

Pass collection walks the real r3f instance tree (group.current.__r3f
.children) and rebuilds the pass list from scratch whenever it or the
composer changes, so the order always matches current JSX — including
through wrapper components — after a reorder or a remount.

While testing the reorder case, found a genuine bug in r3f's host
config: when React moves (not remounts) an existing child — e.g. a
key-preserving reorder of a keyed effect list — insertBefore/
appendChild splice the moved instance into its new slot without
detaching it from the old one first, leaving a stale duplicate in
Instance.children. Worth reporting upstream. Added a cheap dedupe
(keep each object's last occurrence, sorted by that position) that
recovers the correct order either way and is a no-op when the bug
isn't present.
r3f explicitly never auto-disposes objects rendered via <primitive
object={...}> ("their state may be kept outside of React"), regardless
of dispose={null}. Several effects that render this way had no cleanup
at all (ASCII, ColorAverage, SelectiveBloom, SSAO), so they leaked
their underlying postprocessing effect/texture on every unmount and
every prop change that recreates the instance. Added a small useDispose
hook (util.tsx) and wired it into every <primitive>-based effect, with
a WeakSet guard against double-dispose across StrictMode's dev-only
mount/cleanup/mount cycle.

Also fixes GodRays, which was declared as (props, ref) without
forwardRef — under React 19 that ref parameter is never populated, so
consumers passing a ref to GodRays silently got nothing.
Named imports instead of namespace imports, plus LensFlare's wrapEffect
import path (moved to its own module in an earlier commit here).
Covers pass registration/composition order — including reorder with
identity preserved, and instance recreation via a prop-driven arg
change — plus composer StrictMode disposal and wrapEffect's dispose
timing.

Replaces the old root-level EffectComposer.test.tsx (superseded,
predates this suite and used a different mock-root setup).
The args useMemo used JSON.stringify(props) as a change-fingerprint,
which throws "Converting circular structure to JSON" whenever a prop
holds an object with a back-reference (textures, refs, and other
three.js objects commonly do). Two independent reports hit this on
ordinary usage (a texture on LensFlare, animating via ref on
Scanline). Swapped in a stringify that breaks cycles with a
placeholder instead of throwing — it's only ever used as a memo key,
not real serialization, so that's sufficient.
Already fixed as of the useVector2 adoption a few commits back —
this just locks it in so it can't regress silently.
JSON.stringify calls a value's own toJSON before our replacer ever
sees it, so the circular-safe stringify from the last commit still
paid for it: THREE.Texture.toJSON() re-encodes the whole image to a
base64 data URL, on every single render, just to compute a fingerprint
that's immediately discarded. Measured ~39ms for a 512x512 texture —
enough to reliably drop a frame, exactly the kind of stutter #334
(LensFlare + a dirt texture, "circular structure... on every resize")
would have produced even before the crash.

Fingerprinting now walks own properties directly instead of going
through JSON.stringify/toJSON, with typed arrays/buffers collapsed to
an identity token — Object.keys() on one returns every numeric index,
so walking a texture's backing buffer element-by-element measured
~340ms, worse than the toJSON it replaces. Identity is enough: same
buffer means unchanged, a new one means recreate. Verified both the
toJSON call is gone and identity-based recreate/reuse still behaves
correctly.
The old wrapEffect never destructured ref out of props, so it landed
in ...props and got included in the args fingerprint. Once mounted,
ref.current pointed at the effect instance itself, so every render
after the first fingerprinted a different value (null, then the
instance) and recreated the instance — a permanent, silent rebuild
loop, not just an occasional crash. Confirmed by temporarily
reproducing the old (non-destructured) signature: this test fails, not
with a thrown exception in this environment, but by asserting the
instance never stabilizes across re-renders. Current wrapEffect
destructures ref explicitly, so it never reaches the fingerprint.
children was typed as JSX.Element | JSX.Element[], which rejects the
extremely common `condition && <Effect/>` idiom (that expression is
`false | Element`, and `false` isn't assignable to either half of the
union) — forced callers into a cast to render an effect conditionally.
The runtime already tolerates arbitrary children gracefully (the
tree-walk in EffectComposer just filters for `instanceof Effect ||
instanceof Pass` and ignores anything else), so the stricter type
wasn't buying any actual safety, just rejecting a valid pattern.
Pre-existing in master, unrelated to this branch's other changes.

Confirmed via an isolated repro that this only typechecks against the
new signature and fails against the old one. Note: this project's
tsconfig excludes *.test.* from typecheck entirely (vitest transforms
via esbuild, which strips types without checking them), so the test
added here verifies the *runtime* behavior only — it is not a
compile-time regression guard for this specific fix.
EffectComposerImpl.dispose() disposes every pass it holds, including these two added via composer.addPass — if Autofocus unmounts alongside its ancestor EffectComposer, its own cleanup disposed them a second time.
Mounts each effect with minimal props inside a real EffectComposer and checks it doesn't crash and disposes correctly; a coverage test fails CI if a new file under src/effects isn't added to the manifest or the exclusion list.
@kvvasuu
kvvasuu requested a review from krispya July 27, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant