Repository navigation
circular structure to JSON --> starting at object with constructor #334
Description
Activity
@Saar1985 having the same issue, and I'm pretty sure it's because of this line here:
https://github.com/pmndrs/react-postprocessing/blob/master/src/util.tsx#L34
I guess as a quick way to check if the values of any prop have changed, they're using the JSON-stringified props as a dependency for
useMemo. It doesn't seem to have anything to do with the actual functionality of the effect. Sadly in this case the props also contain aref, which has a circular structure (I guess because it's a JSX element).I tried copying the whole LensFlare.tsx and using my modified version of
wrapEffect, where I specifically removedreffrom the props being JSON-ified, and this got rid of the error.However, the effect itself seems to break everything else in my scene, my lighting & environment map is completely gone.
@jakubfiala confirming it's indeed
wrapEffectMy workaround so far is to use the one recommended in the docs and it seems to do the work for me albeit not being as robust in some very specific case (e.g. passing properties directly as props of the effect, I'll need to look into improving that) and lacks things like blending or opacity. It's really just to get going
export const MyCustomEffect = forwardRef(({ param }, ref) => { const effect = useMemo(() => new MyCustomEffectImpl(param), [param]) return <primitive ref={ref} object={effect} dispose={null} /> })
or for React 19
export const MyCustomEffect = ({ ref, param = {} }) => { const effect = useMemo(() => new MyCustomEffectImpl(param), [param]); return <primitive ref={ref} object={effect} dispose={null} />; };
cc @krispya what I mentioned to you the other day something I'd be down to look into with you
Unfortunately this breaks quite a few other effects in the package e.g.
ChromaticAberration.For that I'd suggest, in the meantime, importing them from
postprocessinginstead and using thewrapEffectfunction suggested in the docsConfirmed with React 19.2.7, @react-three/fiber 9.6.1, and @react-three/postprocessing 3.0.4.
The React 19-specific trigger is
refbecoming a regular function-component prop.wrapEffectleaves it insideprops, and after the intrinsic mounts,ref.currentpoints at the effect's R3F instance graph. The next render reaches[JSON.stringify(props)]and followschildren[0].parentback into the graph.There is a second bug on the same path: even when serialization succeeds, attaching the ref changes the memo signature from
ref.current === nullto the live instance. That reconstructs the effect once, causing a visual pop and leaking the outgoing GPU effect unless the caller happens to dispose it.Minimal React 19-compatible source fix for the ref case:
-export const wrapEffect = <T extends EffectConstructor>(effect: T, defaults?: EffectProps<T>) => - function Effect({ blendFunction = defaults?.blendFunction, opacity = defaults?.opacity, ...props }) { +export const wrapEffect = <T extends EffectConstructor>(effect: T, defaults?: EffectProps<T>) => + function Effect({ blendFunction = defaults?.blendFunction, opacity = defaults?.opacity, ref, ...props }) { @@ return ( <Component + ref={ref} camera={camera}
That makes ref attachment non-constructive and removes the reported cycle. A cycle-aware replacer can make the remaining stringify fail-safe, but
JSON.stringifyis still not a sound general constructor-identity mechanism: functions/symbols disappear and distinct object graphs can collide. The durable API direction is to make explicitargsthe constructor identity and apply ordinary effect props to the stable instance, or otherwise use a cycle-safe structural comparator with defined function/object identity semantics.For custom effects, the robust workaround is one owned instance plus explicit cleanup:
function CustomEffect({ ref, opacity = 1 }) { const effect = useMemo(() => new CustomEffectImpl(), []) useLayoutEffect(() => { effect.blendMode.opacity.value = opacity }, [effect, opacity]) useEffect(() => () => effect.dispose(), [effect]) return <primitive ref={ref} object={effect} dispose={null} /> }
We reproduced the resize/scale-change crash, applied the narrow wrapper fix for stock effects, migrated owned effects to the stable-instance pattern, and re-ran the same browser interaction with
pageErrors: [].Reproduced this deterministically in the repo's own vitest harness (virtual R3F root,
frameloop: 'never'), and verified that #338 fixes it.Minimal repro — any wrapped effect + an object ref + one re-render after mount:
const ref = React.createRef<BloomEffect>() const App = ({ intensity }: { intensity: number }) => ( <EffectComposer> <Bloom ref={ref} intensity={intensity} /> </EffectComposer> ) await act(async () => root.render(<App intensity={1} />)) // mounts; ref.current = effect instance await act(async () => root.render(<App intensity={2} />)) // throws: Converting circular structure to JSON
The first render is fine because
ref.currentis stillnullwhenJSON.stringify(props)runs. After mount it holds the R3F instance graph, so the next render's stringify walkschildren[0].parentback into itself — which is why the OP sees it on resize: any re-render after mount hits it.Results (React 19.2.7, @react-three/fiber 9.6.1, @react-three/postprocessing 3.0.4):
- master: throws as above
- master + fix: Fix ref as prop causing circular reference in useMemo in React 19 #338 applied: passes
Until a release includes #338, a zero-patch workaround on 3.0.4 is a callback ref instead of an object ref —
JSON.stringifydrops function values, so it never recurses:<Bloom ref={(e) => { bloomRef.current = e }} intensity={intensity} />
Would be good to see #338 land.
- added a commit that references this issue
on Jul 25, 2026 - added a commit that references this issue
on Jul 30, 2026
I have version 3.0.4 , after adding Lens flare have this error on every page resize
Uncaught TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Object'
| property 'children' -> object with constructor 'Array'
| index 0 -> object with constructor 'Object'
--- property 'parent' closes the circle
at JSON.stringify ()
at util.tsx:38:7
at react-stack-bottom-frame (react-reconciler.development.js:14492:20)
at renderWithHooks (react-reconciler.development.js:3914:22)
My Effects.jsx code
// Effects.jsx
import React, { useMemo } from 'react';
import { EffectComposer, Bloom, LensFlare } from '@react-three/postprocessing';
import { useTexture } from "@react-three/drei";
import { Color } from 'three';
export function Effects() {
const lensDirtTexture = useTexture('/lensDirtTexture.png');
}