Skip to content

Resolution holds a circular back-pointer to its parent pass: breaks JSON.stringify, structuredClone, and React 19 dev mode #742

@Lea081200

Description

@Lea081200

Description of the bug

The Resolution class in postprocessing holds a resizable back-pointer to the parent pass that owns it (set in the constructor, used by setBaseSize to call resizable.setSize(...)). This creates a parent ↔ child cycle on every pass that constructs a Resolution — most notably KawaseBlurPass, which is used internally by BloomEffect. The cycle makes those effects unsafe to traverse with any standard graph-walking API:

  • JSON.stringify — throws TypeError: Converting circular structure to JSON
  • structuredClone — throws DataCloneError
  • postMessage, devtools deep-inspect, and any consumer that hashes/serializes the effect graph

The most visible downstream impact is that @react-three/postprocessing is currently unusable under React 19 with reactStrictMode: true (the default for new Next.js 16+ projects). React 19's reconciler calls JSON.stringify on effect props during dev-mode prop diffing / error formatting, hits the cycle, and crashes the entire R3F tree.

The back-pointer is also functionally redundant: Resolution already extends EventDispatcher and dispatches a 'change' event from setBaseSize. Parents can subscribe to that event instead of being held by reference, removing the cycle without changing observable behavior.

To reproduce

Minimal repro (no React, no R3F, plain Node or browser console):

import { KawaseBlurPass } from 'postprocessing'

const pass = new KawaseBlurPass()
JSON.stringify(pass)
// TypeError: Converting circular structure to JSON
//     --> starting at object with constructor 'KawaseBlurPass'
//     |     property 'resolution' -> object with constructor 'Resolution'
//     --- property 'resizable' closes the circle

structuredClone(pass) throws DataCloneError for the same reason.

Real-world repro (Next.js 16 + React 19 + R3F 9 + the JSX wrapper):

next.config.mjs:

const nextConfig = { reactStrictMode: true }
export default nextConfig

A client component:

'use client'
import { Canvas } from '@react-three/fiber'
import { EffectComposer, Bloom } from '@react-three/postprocessing'

export default function Scene() {
  return (
    <Canvas>
      <mesh>
        <boxGeometry />
        <meshStandardMaterial />
      </mesh>
      <EffectComposer>
        <Bloom intensity={0.5} luminanceThreshold={0.9} mipmapBlur />
      </EffectComposer>
    </Canvas>
  )
}

npm run dev and load the page. Console:

Uncaught TypeError: Converting circular structure to JSON
    --> starting at object with constructor 'KawaseBlurPass'
    |     property 'resolution' -> object with constructor 'Resolution'
    --- property 'resizable' closes the circle
    at JSON.stringify (<anonymous>)
    ...
    at ScenePost
    at SceneDirector
    at Canvas

The page never paints. Disabling reactStrictMode masks the symptom but doesn't fix the underlying cycle.

Expected behavior

JSON.stringify(pass) and structuredClone(pass) should not throw on any pass that uses a Resolution, and the @react-three/postprocessing JSX wrapper should mount cleanly under React 19 + StrictMode.

The proposed fix is to drop the resizable back-pointer from Resolution and have parents subscribe to the existing 'change' event:

// Resolution.ts
class Resolution extends EventDispatcher {
  constructor(baseWidth = 1, baseHeight = 1, scale = 1) {
    super()
    this.baseWidth = baseWidth
    this.baseHeight = baseHeight
    this.scale = scale
    // (no `this.resizable` field)
  }

  setBaseSize(width, height) {
    if (this.baseWidth !== width || this.baseHeight !== height) {
      this.baseWidth = width
      this.baseHeight = height
      this.dispatchEvent({ type: 'change' })
      // (no `this.resizable.setSize(...)` call)
    }
  }
}
// In every pass that owns a Resolution, e.g. KawaseBlurPass:
class KawaseBlurPass extends Pass {
  constructor(/* ... */) {
    super()
    this.resolution = new Resolution(width, height, scale)
    this.resolution.addEventListener('change', () => {
      this.setSize(this.resolution.baseWidth, this.resolution.baseHeight)
    })
  }
}

Same observable behavior, no cycle, no JSON.stringify trap, no structuredClone trap, no future-React reconciler trap. This is a breaking change for any consumer that constructs Resolution directly with the legacy (resizable, baseWidth, baseHeight, scale) signature, so it would land in a major version (or behind a deprecation pass that accepts both signatures). It's also worth ensuring the v7 RenderPipeline redesign in #419 keeps Resolution back-pointer-free for the same reasons.

Happy to PR this if maintainers agree on the approach.

Screenshots

N/A - bug is a console error, fully captured in the To reproduce section.

Library versions used

  • Three: 0.183.2
  • Post Processing: 6.39.0

(also reproduced via @react-three/postprocessing@3.0.4, @react-three/fiber@9.5.0, react@19.2.4, next@16.2.2)

Desktop

  • OS: Windows 11
  • Browser Chrome 131.0.6778.86
  • Graphics hardware: NVIDIA

Mobile

N/A - bug reproduces on every platform that runs JavaScript. Not device-specific.

Metadata

Metadata

Assignees

No one assigned

    Labels

    compatibilityA problem that affects compatibility with external librariesenhancementEnhancement of existing functionality

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions