Skip to content

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

Closed

Description

@leamariek

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/[email protected], @react-three/[email protected], [email protected], [email protected])

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.

Activity

  1. vanruesc commented on Apr 9, 2026

    @vanruesc
    Member

    Thanks for the report.

    I think I'm becoming paranoid, but this reads a bit like a ChatGPT summary 😅

    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.

    The Resolution.resizable property has been deprecated since 2022.

    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).

    True, I'll just remove it in a backward-compatible way for the next release.

    It's also worth ensuring the v7 RenderPipeline redesign in #419 keeps Resolution back-pointer-free for the same reasons.

    That's already the case. Thanks for the heads-up though 👍

  2. added
    compatibilityA problem that affects compatibility with external libraries
    enhancementEnhancement of existing functionality
    on Apr 9, 2026
  3. added a commit that references this issue on Apr 17, 2026
    0933567
  4. vanruesc commented on Apr 17, 2026

    @vanruesc
    Member

    Should be fixed in v6.39.1

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

    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