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.
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:
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):
structuredClone(pass)throwsDataCloneErrorfor the same reason.Real-world repro (Next.js 16 + React 19 + R3F 9 + the JSX wrapper):
next.config.mjs:A client component:
npm run devand load the page. Console:The page never paints. Disabling
reactStrictModemasks the symptom but doesn't fix the underlying cycle.Expected behavior
JSON.stringify(pass)andstructuredClone(pass)should not throw on any pass that uses aResolution, and the@react-three/postprocessingJSX wrapper should mount cleanly under React 19 + StrictMode.The proposed fix is to drop the
resizableback-pointer fromResolutionand have parents subscribe to the existing'change'event:Same observable behavior, no cycle, no
JSON.stringifytrap, nostructuredClonetrap, no future-React reconciler trap. This is a breaking change for any consumer that constructsResolutiondirectly 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 v7RenderPipelineredesign in #419 keepsResolutionback-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
(also reproduced via
@react-three/postprocessing@3.0.4,@react-three/fiber@9.5.0,react@19.2.4,next@16.2.2)Desktop
Mobile
N/A - bug reproduces on every platform that runs JavaScript. Not device-specific.