Skip to content

circular structure to JSON --> starting at object with constructor #334

Description

@Saar1985

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');

// use simple, serializable props instead of complex Three.js classes directly
const lensFlareProps = useMemo(() => ({
    enabled: true,
    opacity: 1.0,
    position: { x: -25, y: 6, z: -60 },
    glareSize: 0.35,
    starPoints: 6,
    animated: true,
    followMouse: false,
    anamorphic: false,
    colorGain: new Color('#38160b'), // Hex string converted safely here
    flareSpeed: 0.4,
    flareShape: 0.1,
    flareSize: 0.005,
    secondaryGhosts: true,
    ghostScale: 0.1,
    aditionalStreaks: true,
    starBurst: true,
    haloScale: 0.5,
    lensDirtTexture: lensDirtTexture
}), [lensDirtTexture]);

return (
    <EffectComposer>
        <Bloom
            luminanceThreshold={0.5}
            mipmapBlur
            luminanceSmoothing={0.2}
            intensity={0.2}
        />
        <LensFlare {...lensFlareProps} />
    </EffectComposer>
);

}

Activity

  1. jakubfiala commented on May 6, 2025

    @jakubfiala

    @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 a ref, 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 removed ref from 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.

  2. MaximeHeckel commented on Jun 18, 2025

    @MaximeHeckel
    Contributor

    @jakubfiala confirming it's indeed wrapEffect

    My 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 postprocessing instead and using the wrapEffect function suggested in the docs

  3. AACOS-ai commented on Jul 13, 2026

    @AACOS-ai

    Confirmed with React 19.2.7, @react-three/fiber 9.6.1, and @react-three/postprocessing 3.0.4.

    The React 19-specific trigger is ref becoming a regular function-component prop. wrapEffect leaves it inside props, and after the intrinsic mounts, ref.current points at the effect's R3F instance graph. The next render reaches [JSON.stringify(props)] and follows children[0].parent back 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 === null to 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.stringify is still not a sound general constructor-identity mechanism: functions/symbols disappear and distinct object graphs can collide. The durable API direction is to make explicit args the 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: [].

  4. yourlcfr commented on Jul 21, 2026

    @yourlcfr

    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.current is still null when JSON.stringify(props) runs. After mount it holds the R3F instance graph, so the next render's stringify walks children[0].parent back 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):

    Until a release includes #338, a zero-patch workaround on 3.0.4 is a callback ref instead of an object ref — JSON.stringify drops function values, so it never recurses:

    <Bloom ref={(e) => { bloomRef.current = e }} intensity={intensity} />

    Would be good to see #338 land.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions