diff --git a/package.json b/package.json index 6419f01a..c7c6c901 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@eslint/compat": "^2.1.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "^9.39.5", - "@react-three/fiber": "^9.6.1", + "@react-three/fiber": "^9.7.0", "@types/node": "^26.1.1", "@types/react": "^19.2.17", "@types/three": "^0.182.0", diff --git a/src/EffectComposer.test.tsx b/src/EffectComposer.test.tsx deleted file mode 100644 index 0d5671c8..00000000 --- a/src/EffectComposer.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import * as React from 'react' -import * as THREE from 'three' -import { describe, it, expect } from 'vitest' -import { extend, createRoot } from '@react-three/fiber' -import { EffectComposer } from './EffectComposer' -import { EffectComposer as EffectComposerImpl, RenderPass, Pass, Effect, EffectPass } from 'postprocessing' - -// Let React know that we'll be testing effectful components -declare global { - var IS_REACT_ACT_ENVIRONMENT: boolean -} -global.IS_REACT_ACT_ENVIRONMENT = true - -// Create virtual R3F root for testing -extend(THREE as any) -const root = createRoot({ - style: {} as CSSStyleDeclaration, - addEventListener: (() => {}) as any, - removeEventListener: (() => {}) as any, - width: 1280, - height: 800, - clientWidth: 1280, - clientHeight: 800, - getContext: (() => - new Proxy( - {}, - { - get(_target, prop) { - switch (prop) { - case 'getParameter': - return () => 'WebGL 2' // GL_VERSION - case 'getExtension': - return () => ({}) // EXT_blend_minmax - case 'getContextAttributes': - return () => ({ alpha: true }) - case 'getShaderPrecisionFormat': - return () => ({ rangeMin: 1, rangeMax: 1, precision: 1 }) - default: - return () => {} - } - }, - } - )) as any, -} satisfies Partial as HTMLCanvasElement) -root.configure({ frameloop: 'never' }) - -const EFFECT_SHADER = 'mainImage() {}' - -describe('EffectComposer', () => { - it('should merge effects together', async () => { - const composerRef = React.createRef() - - const effectA = new Effect('A', EFFECT_SHADER) - const effectB = new Effect('B', EFFECT_SHADER) - const effectC = new Effect('C', EFFECT_SHADER) - const passA = new Pass() - const passB = new Pass() - - // Forward order - await React.act(async () => - root.render( - - {/* EffectPass(effectA, effectB) */} - - - {/* PassA */} - - {/* EffectPass(effectC) */} - - {/* PassB */} - - - ) - ) - expect(composerRef.current!.passes.map((p) => p.constructor)).toStrictEqual([ - RenderPass, - EffectPass, - Pass, - EffectPass, - Pass, - ]) - // @ts-expect-error - expect((composerRef.current!.passes[1] as EffectPass).effects).toStrictEqual([effectA, effectB]) - expect(composerRef.current!.passes[2]).toBe(passA) - // @ts-expect-error - expect((composerRef.current!.passes[3] as EffectPass).effects).toStrictEqual([effectC]) - expect(composerRef.current!.passes[4]).toBe(passB) - - // NOTE: instance children ordering is unstable until R3F v9, so we remount from scratch - await React.act(async () => root.render(null)) - - // Reverse order - await React.act(async () => - root.render( - - {/* PassB */} - - {/* EffectPass(effectC) */} - - {/* PassA */} - - {/* EffectPass(effectB, effectA) */} - - - - ) - ) - expect(composerRef.current!.passes.map((p) => p.constructor)).toStrictEqual([ - RenderPass, - Pass, - EffectPass, - Pass, - EffectPass, - ]) - expect(composerRef.current!.passes[1]).toBe(passB) - // @ts-expect-error - expect((composerRef.current!.passes[2] as EffectPass).effects).toStrictEqual([effectC]) - expect(composerRef.current!.passes[3]).toBe(passA) - // @ts-expect-error - expect((composerRef.current!.passes[4] as EffectPass).effects).toStrictEqual([effectB, effectA]) - }) - - it.skip('should split convolution effects', async () => { - await React.act(async () => root.render(null)) - }) -}) diff --git a/src/EffectComposer.tsx b/src/EffectComposer.tsx index 098253b1..7547b80f 100644 --- a/src/EffectComposer.tsx +++ b/src/EffectComposer.tsx @@ -1,27 +1,28 @@ -import type { TextureDataType, Group, Camera, Scene } from 'three' -import { HalfFloatType, NoToneMapping } from 'three' -import { - type JSX, - memo, - forwardRef, - useMemo, - useEffect, - useLayoutEffect, - createContext, - useRef, - useImperativeHandle, -} from 'react' -import { useThree, useFrame, type Instance } from '@react-three/fiber' +import { useFrame, useThree, type Instance } from '@react-three/fiber' import { + DepthDownsamplingPass, + Effect, + EffectAttribute, EffectComposer as EffectComposerImpl, - RenderPass, EffectPass, NormalPass, - DepthDownsamplingPass, - Effect, Pass, - EffectAttribute, + RenderPass, } from 'postprocessing' +import { + createContext, + memo, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, + type Ref, +} from 'react' +import type { Camera, Group, Scene, TextureDataType } from 'three' +import { HalfFloatType, NoToneMapping } from 'three' export const EffectComposerContext = /* @__PURE__ */ createContext<{ composer: EffectComposerImpl @@ -34,7 +35,7 @@ export const EffectComposerContext = /* @__PURE__ */ createContext<{ export type EffectComposerProps = { enabled?: boolean - children: JSX.Element | JSX.Element[] + children: ReactNode depthBuffer?: boolean /** Only used for SSGI currently, leave it disabled for everything else unless it's needed */ enableNormalPass?: boolean @@ -46,155 +47,191 @@ export type EffectComposerProps = { renderPriority?: number camera?: Camera scene?: Scene + ref?: Ref +} + +type ComposerState = { + composer: EffectComposerImpl + normalPass: NormalPass | null + downSamplingPass: DepthDownsamplingPass | null } const isConvolution = (effect: Effect): boolean => (effect.getAttributes() & EffectAttribute.CONVOLUTION) === EffectAttribute.CONVOLUTION -export const EffectComposer = /* @__PURE__ */ memo( - /* @__PURE__ */ forwardRef( - ( - { - children, - camera: _camera, - scene: _scene, - resolutionScale, - enabled = true, - renderPriority = 1, - autoClear = true, - depthBuffer, - enableNormalPass, - stencilBuffer, - multisampling = 8, - frameBufferType = HalfFloatType, - }, - ref - ) => { - const { gl, scene: defaultScene, camera: defaultCamera, size } = useThree() - const scene = _scene || defaultScene - const camera = _camera || defaultCamera - - const [composer, normalPass, downSamplingPass] = useMemo(() => { - // Initialize composer - const effectComposer = new EffectComposerImpl(gl, { - depthBuffer, - stencilBuffer, - multisampling, - frameBufferType, - }) - - // Add render pass - effectComposer.addPass(new RenderPass(scene, camera)) - - // Create normal pass - let downSamplingPass = null - let normalPass = null - if (enableNormalPass) { - normalPass = new NormalPass(scene, camera) - normalPass.enabled = false - effectComposer.addPass(normalPass) - if (resolutionScale !== undefined) { - downSamplingPass = new DepthDownsamplingPass({ normalBuffer: normalPass.texture, resolutionScale }) - downSamplingPass.enabled = false - effectComposer.addPass(downSamplingPass) - } +/** + * Groups a flat, ordered list of Effect/Pass instances into actual composer + * passes, merging consecutive non-convolution Effects into a single + * EffectPass. + */ +function buildPasses(nodes: Array, camera: Camera): Pass[] { + const passes: Pass[] = [] + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + + if (node instanceof Effect) { + const effects: Effect[] = [node] + + if (!isConvolution(node)) { + let next: Effect | Pass | undefined + while ((next = nodes[i + 1]) instanceof Effect) { + if (isConvolution(next)) break + effects.push(next) + i++ } + } - return [effectComposer, normalPass, downSamplingPass] - }, [ - camera, - gl, - depthBuffer, - stencilBuffer, - multisampling, - frameBufferType, - scene, - enableNormalPass, - resolutionScale, - ]) - - useEffect(() => composer?.setSize(size.width, size.height), [composer, size]) - useFrame( - (_, delta) => { - if (enabled) { - const currentAutoClear = gl.autoClear - gl.autoClear = autoClear - if (stencilBuffer && !autoClear) gl.clearStencil() - composer.render(delta) - gl.autoClear = currentAutoClear - } - }, - enabled ? renderPriority : 0 - ) + passes.push(new EffectPass(camera, ...effects)) + } else if (node instanceof Pass) { + passes.push(node) + } + } - const group = useRef(null!) - useLayoutEffect(() => { - const passes: Pass[] = [] + return passes +} - // TODO: rewrite all of this with R3F v9 - const groupInstance = (group.current as Group & { __r3f: Instance }).__r3f +export const EffectComposer = /* @__PURE__ */ memo(function EffectComposer({ + children, + camera: _camera, + scene: _scene, + resolutionScale, + enabled = true, + renderPriority = 1, + autoClear = true, + depthBuffer, + enableNormalPass, + stencilBuffer, + multisampling = 8, + frameBufferType = HalfFloatType, + ref, +}: EffectComposerProps) { + const { gl, scene: defaultScene, camera: defaultCamera, size } = useThree() + const scene = _scene || defaultScene + const camera = _camera || defaultCamera + + // EffectComposer owns WebGL resources, so it must be created and + // disposed inside an effect lifecycle. useMemo is not suitable here + // because React may discard memoized values without running cleanup. + const [composerState, setComposerState] = useState(null) + + useEffect(() => { + const effectComposer = new EffectComposerImpl(gl, { depthBuffer, stencilBuffer, multisampling, frameBufferType }) + effectComposer.addPass(new RenderPass(scene, camera)) + + let normalPass: NormalPass | null = null + let downSamplingPass: DepthDownsamplingPass | null = null + + if (enableNormalPass) { + normalPass = new NormalPass(scene, camera) + normalPass.enabled = false + effectComposer.addPass(normalPass) + + if (resolutionScale !== undefined) { + downSamplingPass = new DepthDownsamplingPass({ normalBuffer: normalPass.texture, resolutionScale }) + downSamplingPass.enabled = false + effectComposer.addPass(downSamplingPass) + } + } - if (groupInstance && composer) { - const children = groupInstance.children + effectComposer.setSize(size.width, size.height) - for (let i = 0; i < children.length; i++) { - const child = children[i].object + setComposerState({ composer: effectComposer, normalPass, downSamplingPass }) - if (child instanceof Effect) { - const effects: Effect[] = [child] + return () => { + effectComposer.dispose() + } + // `size` intentionally excluded: it's applied via the composer.setSize + // effect below, and shouldn't tear down/recreate the whole composer. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [camera, gl, depthBuffer, stencilBuffer, multisampling, frameBufferType, scene, enableNormalPass, resolutionScale]) + + useEffect(() => { + composerState?.composer.setSize(size.width, size.height) + }, [composerState, size]) + + useFrame( + (_, delta) => { + if (!enabled || !composerState) return + const { composer } = composerState + const currentAutoClear = gl.autoClear + gl.autoClear = autoClear + if (stencilBuffer && !autoClear) gl.clearStencil() + composer.render(delta) + gl.autoClear = currentAutoClear + }, + enabled ? renderPriority : 0 + ) - if (!isConvolution(child)) { - let next: unknown = null - while ((next = children[i + 1]?.object) instanceof Effect) { - if (isConvolution(next)) break - effects.push(next) - i++ - } - } + // Passes are derived from the actual r3f scene graph rather than tracked + // incrementally, so the list always matches current JSX order — including + // through wrapper components — even after a reorder or a remount. + const group = useRef(null!) - const pass = new EffectPass(camera, ...effects) - passes.push(pass) - } else if (child instanceof Pass) { - passes.push(child) - } - } + useLayoutEffect(() => { + if (!composerState) return + const { composer, normalPass, downSamplingPass } = composerState - for (const pass of passes) composer?.addPass(pass) + const passes: Pass[] = [] + const groupInstance = (group.current as Group & { __r3f: Instance }).__r3f - if (normalPass) normalPass.enabled = true - if (downSamplingPass) downSamplingPass.enabled = true - } + if (groupInstance) { + const nodes = groupInstance.children.map((child) => child.object).filter( + (object): object is Effect | Pass => object instanceof Effect || object instanceof Pass + ) - return () => { - for (const pass of passes) composer?.removePass(pass) - if (normalPass) normalPass.enabled = false - if (downSamplingPass) downSamplingPass.enabled = false - } - }, [composer, children, camera, normalPass, downSamplingPass]) - - // Disable tone mapping because threejs disallows tonemapping on render targets - useEffect(() => { - const currentTonemapping = gl.toneMapping - gl.toneMapping = NoToneMapping - return () => { - gl.toneMapping = currentTonemapping - } - }, [gl]) + passes.push(...buildPasses(nodes, camera)) + } - // Memoize state, otherwise it would trigger all consumers on every render - const state = useMemo( - () => ({ composer, normalPass, downSamplingPass, resolutionScale, camera, scene }), - [composer, normalPass, downSamplingPass, resolutionScale, camera, scene] - ) + for (const pass of passes) composer.addPass(pass) - // Expose the composer - useImperativeHandle(ref, () => composer, [composer]) + if (passes.length) { + if (normalPass) normalPass.enabled = true + if (downSamplingPass) downSamplingPass.enabled = true + } - return ( - - {children} - - ) + return () => { + for (const pass of passes) composer.removePass(pass) + if (normalPass) normalPass.enabled = false + if (downSamplingPass) downSamplingPass.enabled = false + } + }, [composerState, children, camera]) + + // Disable tone mapping because threejs disallows tonemapping on render targets + useEffect(() => { + const currentTonemapping = gl.toneMapping + gl.toneMapping = NoToneMapping + return () => { + gl.toneMapping = currentTonemapping } + }, [gl]) + + // Memoize state, otherwise it would trigger all consumers on every render + const state = useMemo( + () => + composerState + ? { + composer: composerState.composer, + normalPass: composerState.normalPass, + downSamplingPass: composerState.downSamplingPass, + resolutionScale, + camera, + scene, + } + : null, + [composerState, resolutionScale, camera, scene] + ) + + // Expose the composer + useImperativeHandle(ref, () => composerState?.composer as EffectComposerImpl, [composerState]) + + // Wait until the composer exists before mounting children so they always + // see a valid composer instance via context. + if (!state) return null + + return ( + + {children} + ) -) +}) diff --git a/src/tests/EffectComposer.test.tsx b/src/tests/EffectComposer.test.tsx new file mode 100644 index 00000000..c30fd760 --- /dev/null +++ b/src/tests/EffectComposer.test.tsx @@ -0,0 +1,640 @@ +import { + ColorAverageEffect, + DepthDownsamplingPass, + Effect, + EffectComposer as EffectComposerImpl, + EffectPass, + NormalPass, + RenderPass, +} from 'postprocessing' +import * as React from 'react' +import * as THREE from 'three' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { ColorAverage } from '../effects/ColorAverage' +import { wrapEffect } from '../wrapEffect' +import { EFFECT_SHADER, flush, root, strict, waitForComposer, waitForEffects, waitForNewComposer } from './test-utils' + +class EffectA extends Effect { + constructor() { + super('EffectA', EFFECT_SHADER) + } +} + +class EffectB extends Effect { + constructor() { + super('EffectB', EFFECT_SHADER) + } +} + +class EffectC extends Effect { + constructor() { + super('EffectC', EFFECT_SHADER) + } +} + +const WrappedEffectA = wrapEffect(EffectA) +const WrappedEffectB = wrapEffect(EffectB) +const WrappedEffectC = wrapEffect(EffectC) + +afterEach(async () => { + await React.act(async () => { + root.render(null) + }) +}) + +describe('EffectComposer', () => { + describe('registration and composition', () => { + it('merges wrapped effects into a single EffectPass', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + + + ) + ) + + const composer = await waitForComposer(ref) + + expect(composer.passes).toHaveLength(2) + expect(composer.passes[0]).toBeInstanceOf(RenderPass) + expect(composer.passes[1]).toBeInstanceOf(EffectPass) + + const effects = await waitForEffects(ref, 3) + + expect(effects[0]).toBeInstanceOf(EffectA) + expect(effects[1]).toBeInstanceOf(EffectB) + expect(effects[2]).toBeInstanceOf(EffectC) + }) + + it('preserves registration order on initial mount', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + + + + ) + ) + + const effects = await waitForEffects(ref, 4) + + expect(effects[0]).toBeInstanceOf(EffectC) + expect(effects[1]).toBeInstanceOf(EffectA) + expect(effects[2]).toBeInstanceOf(EffectB) + expect(effects[3]).toBeInstanceOf(ColorAverageEffect) + }) + + it('keeps JSX registration order after the child list changes', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + + ) + ) + + let effects = await waitForEffects(ref, 2) + + expect(effects[0]).toBeInstanceOf(EffectC) + expect(effects[1]).toBeInstanceOf(EffectA) + + await React.act(async () => + root.render( + + + + + ) + ) + + effects = await waitForEffects(ref, 2) + + expect(effects[0]).toBeInstanceOf(EffectB) + expect(effects[1]).toBeInstanceOf(EffectC) + }) + + it('reorders effects when children swap positions in JSX, identity preserved via key', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + + ) + ) + + let effects = await waitForEffects(ref, 2) + expect(effects[0]).toBeInstanceOf(EffectA) + expect(effects[1]).toBeInstanceOf(EffectB) + + const [firstA, firstB] = effects + + await React.act(async () => + root.render( + + + + + ) + ) + + effects = await waitForEffects(ref, 2) + + expect(effects[0]).toBeInstanceOf(EffectB) + expect(effects[1]).toBeInstanceOf(EffectA) + // same underlying instances, just reordered — not a remount + expect(effects[0]).toBe(firstB) + expect(effects[1]).toBe(firstA) + }) + + it('keeps an effect in its slot when its instance is recreated via a prop-driven arg change', async () => { + const ref = React.createRef() + + class MiddleEffect extends Effect { + value: number + constructor({ value = 0 }: { value?: number } = {}) { + super('MiddleEffect', EFFECT_SHADER) + this.value = value + } + } + const WrappedMiddle = wrapEffect(MiddleEffect) + + await React.act(async () => + root.render( + + + + + + ) + ) + + let effects = await waitForEffects(ref, 3) + expect(effects[0]).toBeInstanceOf(EffectA) + expect(effects[1]).toBeInstanceOf(MiddleEffect) + expect(effects[2]).toBeInstanceOf(EffectC) + + const firstMiddle = effects[1] + + await React.act(async () => + root.render( + + + + + + ) + ) + + effects = await waitForEffects(ref, 3) + + expect(effects[0]).toBeInstanceOf(EffectA) + expect(effects[1]).toBeInstanceOf(MiddleEffect) + expect(effects[1]).not.toBe(firstMiddle) // recreated, new instance + expect((effects[1] as InstanceType).value).toBe(2) + expect(effects[2]).toBeInstanceOf(EffectC) + }) + + it('removes only the unmounted effect', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + + ) + ) + + await waitForEffects(ref, 2) + + await React.act(async () => + root.render( + + + + ) + ) + + const effects = await waitForEffects(ref, 1) + + expect(effects[0]).toBeInstanceOf(EffectB) + }) + + it('accepts conditionally-rendered children via `condition && ` without a type cast', async () => { + const ref = React.createRef() + + // Boxed behind a function so TS can't literal-narrow `show` to `false` + // at the call site below — that would make `show && ` + // type as just `false` instead of `boolean | Element`, silently + // defeating the point of this test (a real regression to the old, + // too-narrow `children: JSX.Element | JSX.Element[]` type wouldn't + // get caught by tsc). + const shouldShow = (value: boolean): boolean => value + + await React.act(async () => + root.render({shouldShow(false) && }) + ) + + await flush() + + expect(ref.current!.passes.filter((p) => p instanceof EffectPass)).toHaveLength(0) + + await React.act(async () => + root.render({shouldShow(true) && }) + ) + + const effects = await waitForEffects(ref, 1) + + expect(effects[0]).toBeInstanceOf(EffectA) + }) + + it('recreates the composer when the camera changes and keeps effects', async () => { + const ref = React.createRef() + + const cameraA = new THREE.PerspectiveCamera() + const cameraB = new THREE.PerspectiveCamera() + + await React.act(async () => + root.render( + + + + ) + ) + + const first = await waitForComposer(ref) + + await React.act(async () => + root.render( + + + + ) + ) + + const second = await waitForComposer(ref) + + expect(second).not.toBe(first) + + const effects = await waitForEffects(ref, 1) + + expect(effects[0]).toBeInstanceOf(EffectA) + }) + }) + + describe('cleanup and disposal', () => { + it('unregisters effects and clears the ref on full unmount', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + + await waitForComposer(ref) + expect(ref.current!.passes).toHaveLength(2) + + await React.act(async () => root.render(null)) + + expect(ref.current).toBe(null) + }) + + it('does not leak composer instances in StrictMode', async () => { + const disposeSpy = vi.spyOn(EffectComposerImpl.prototype, 'dispose') + const ref = React.createRef() + + await React.act(async () => + root.render( + strict( + + + + ) + ) + ) + + await waitForComposer(ref) + + await React.act(async () => { + root.render(null) + }) + + expect(disposeSpy).toHaveBeenCalled() + + disposeSpy.mockRestore() + }) + + it('removes passes before disposing the composer, not after', async () => { + const ref = React.createRef() + + const removePassSpy = vi.spyOn(EffectComposerImpl.prototype, 'removePass') + const disposeSpy = vi.spyOn(EffectComposerImpl.prototype, 'dispose') + + await React.act(async () => + root.render( + + + + + ) + ) + + await waitForEffects(ref, 2) + + removePassSpy.mockClear() + disposeSpy.mockClear() + + await React.act(async () => { + root.render(null) + }) + + expect(removePassSpy).toHaveBeenCalled() + expect(disposeSpy).toHaveBeenCalled() + + const lastRemovePassOrder = Math.max(...removePassSpy.mock.invocationCallOrder) + const disposeOrder = disposeSpy.mock.invocationCallOrder[0] + + expect(lastRemovePassOrder).toBeLessThan(disposeOrder) + + removePassSpy.mockRestore() + disposeSpy.mockRestore() + }) + + it('disposes exactly as many composers as it constructs, across repeated prop changes', async () => { + const ref = React.createRef() + const disposeSpy = vi.spyOn(EffectComposerImpl.prototype, 'dispose') + const seenInstances = new Set() + const cycles = 20 + + for (let i = 0; i < cycles; i++) { + await React.act(async () => + root.render( + + + + ) + ) + seenInstances.add(await waitForComposer(ref)) + } + + await React.act(async () => root.render(null)) + + expect(seenInstances.size).toBe(cycles) + expect(disposeSpy).toHaveBeenCalledTimes(cycles) + + disposeSpy.mockRestore() + }) + }) + + describe('StrictMode', () => { + it('preserves effects after the StrictMode fake-unmount/remount cycle', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + strict( + + + + ) + ) + ) + + const firstComposer = await waitForComposer(ref) + const effects = await waitForEffects(ref, 1) + + expect(firstComposer).toBeTruthy() + expect(effects[0]).toBeInstanceOf(EffectA) + + await flush() + + const secondComposer = ref.current + + expect(secondComposer).toBeTruthy() + expect(secondComposer!.passes).toHaveLength(2) + + const secondEffects = await waitForEffects(ref, 1) + + expect(secondEffects[0]).toBeInstanceOf(EffectA) + }) + + it('never creates duplicate EffectPass instances, including when effects are reduced', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + strict( + + + + + + ) + ) + ) + + const composer = await waitForComposer(ref) + const effects = await waitForEffects(ref, 3) + + expect(effects[0]).toBeInstanceOf(EffectA) + expect(effects[1]).toBeInstanceOf(EffectB) + expect(effects[2]).toBeInstanceOf(EffectC) + expect(composer.passes.filter((p) => p instanceof EffectPass)).toHaveLength(1) + + await React.act(async () => + root.render( + strict( + + + + ) + ) + ) + + const reduced = await waitForEffects(ref, 1) + + expect(reduced[0]).toBeInstanceOf(EffectA) + expect(composer.passes.filter((p) => p instanceof EffectPass)).toHaveLength(1) + + await React.act(async () => root.render(null)) + expect(ref.current).toBe(null) + }) + }) + + describe('composer recreation', () => { + it('already has its EffectPass the instant the composer reference changes', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + + const firstComposer = await waitForComposer(ref) + await waitForEffects(ref, 1) + + await React.act(async () => + root.render( + + + + ) + ) + + const secondComposer = await waitForNewComposer(ref, firstComposer) + + expect(secondComposer.passes.filter((p) => p instanceof EffectPass)).toHaveLength(1) + // @ts-expect-error + expect(secondComposer.passes.find((p) => p instanceof EffectPass)!.effects).toHaveLength(1) + }) + + it('preserves registration order across recreation with multiple effects', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + + + ) + ) + + const firstComposer = await waitForComposer(ref) + await waitForEffects(ref, 3) + + await React.act(async () => + root.render( + + + + + + ) + ) + + const secondComposer = await waitForNewComposer(ref, firstComposer) + // @ts-expect-error + const effects = secondComposer.passes.find((p) => p instanceof EffectPass)!.effects + + expect(effects).toHaveLength(3) + expect(effects[0]).toBeInstanceOf(EffectC) + expect(effects[1]).toBeInstanceOf(EffectA) + expect(effects[2]).toBeInstanceOf(EffectB) + }) + + it('enables normalPass/downSamplingPass synchronously on the new composer when effects are already registered', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + + + ) + ) + + const firstComposer = await waitForComposer(ref) + await waitForEffects(ref, 1) + + await React.act(async () => + root.render( + + + + ) + ) + + const secondComposer = await waitForNewComposer(ref, firstComposer) + + const normalPass = secondComposer.passes.find((p) => p instanceof NormalPass) + const downSamplingPass = secondComposer.passes.find((p) => p instanceof DepthDownsamplingPass) + + expect(normalPass).toBeTruthy() + expect(downSamplingPass).toBeTruthy() + expect(normalPass!.enabled).toBe(true) + expect(downSamplingPass!.enabled).toBe(true) + }) + + it('leaves normalPass/downSamplingPass disabled when the composer is recreated before any effect has ever registered', async () => { + const ref = React.createRef() + + await React.act(async () => + root.render( + + <> + + ) + ) + + const firstComposer = await waitForComposer(ref) + + await React.act(async () => + root.render( + + <> + + ) + ) + + const secondComposer = await waitForNewComposer(ref, firstComposer) + + const normalPass = secondComposer.passes.find((p) => p instanceof NormalPass) + const downSamplingPass = secondComposer.passes.find((p) => p instanceof DepthDownsamplingPass) + + expect(normalPass!.enabled).toBe(false) + expect(downSamplingPass!.enabled).toBe(false) + expect(secondComposer.passes.some((p) => p instanceof EffectPass)).toBe(false) + }) + }) + + describe('performance characteristics (documented, not enforced)', () => { + it('rebuilds the EffectPass once per registration when mounting many effects at once', async () => { + const addPassSpy = vi.spyOn(EffectComposerImpl.prototype, 'addPass') + + const ref = React.createRef() + const effectCount = 44 + + await React.act(async () => + root.render( + + {Array.from({ length: effectCount }, (_, i) => ( + + ))} + + ) + ) + + await waitForEffects(ref, effectCount) + + const effectPassAddCalls = addPassSpy.mock.calls.filter(([pass]) => pass instanceof EffectPass).length + + expect(effectPassAddCalls).toBe(1) + + addPassSpy.mockRestore() + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 3e58da25..b00b2316 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16,7 +16,7 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz" integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== -"@babel/core@^7.0.0", "@babel/core@^7.24.4": +"@babel/core@^7.24.4": version "7.29.7" resolved "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz" integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== @@ -151,6 +151,28 @@ resolved "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz" integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow== +"@emnapi/core@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" + integrity sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== + dependencies: + "@emnapi/wasi-threads" "1.2.2" + tslib "^2.4.0" + +"@emnapi/runtime@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.1.tgz#58f1f3d5d81a9b12f793ab688c96371901027c24" + integrity sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz#4c93becf5bfa3b13d1bbdcc06aee38321ad8139a" + integrity sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA== + dependencies: + tslib "^2.4.0" + "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.10.1" resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz" @@ -215,7 +237,7 @@ minimatch "^3.1.5" strip-json-comments "^3.1.1" -"@eslint/js@^9.39.5", "@eslint/js@9.39.5": +"@eslint/js@9.39.5", "@eslint/js@^9.39.5": version "9.39.5" resolved "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz" integrity sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A== @@ -298,6 +320,13 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@napi-rs/wasm-runtime@^1.1.6": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz#c70706532e5827c0932ca6bf43ee2c512f29c639" + integrity sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw== + dependencies: + "@tybys/wasm-util" "^0.10.3" + "@oxc-project/types@=0.139.0": version "0.139.0" resolved "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz" @@ -308,10 +337,10 @@ resolved "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz" integrity sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA== -"@react-three/fiber@^9.6.1": - version "9.6.1" - resolved "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz" - integrity sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg== +"@react-three/fiber@^9.7.0": + version "9.7.0" + resolved "https://registry.yarnpkg.com/@react-three/fiber/-/fiber-9.7.0.tgz#3cb620eafe7ed39540d3ebef4280c7827966c206" + integrity sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw== dependencies: "@babel/runtime" "^7.17.8" "@types/webxr" "*" @@ -324,6 +353,80 @@ use-sync-external-store "^1.4.0" zustand "^5.0.3" +"@rolldown/binding-android-arm64@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz#f58cb9a0a8128ed0582282720528547fc5c035f3" + integrity sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ== + +"@rolldown/binding-darwin-arm64@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz#441144c05a4a831aa75269abc3a4a324374ea707" + integrity sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw== + +"@rolldown/binding-darwin-x64@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz#c82e30652cef52c4af925d5c66c8955a40319816" + integrity sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g== + +"@rolldown/binding-freebsd-x64@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz#c32e9ce7fa1c0fb2b80913a2a3a05c3e907d06b0" + integrity sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA== + +"@rolldown/binding-linux-arm-gnueabihf@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz#ce90b5e22316adeb502ea010582f498cd0604f27" + integrity sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw== + +"@rolldown/binding-linux-arm64-gnu@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz#91947110c4ddaa4eefb004e52688070977085201" + integrity sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q== + +"@rolldown/binding-linux-arm64-musl@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz#eb32b2d4108c1c702b91e8cde8a043eae5caa94d" + integrity sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA== + +"@rolldown/binding-linux-ppc64-gnu@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz#ccb943c11e5a72655cbb02fc1163541ceb640782" + integrity sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg== + +"@rolldown/binding-linux-s390x-gnu@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz#a33731ee567e90b75fac6e5e55307e8a2b3038f0" + integrity sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA== + +"@rolldown/binding-linux-x64-gnu@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz#a74c01aaacedfc11c39b6feba33a5fa0c654949f" + integrity sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ== + +"@rolldown/binding-linux-x64-musl@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz#28cd178494fa1e65dba412229b5ce55c4dd5cbd1" + integrity sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg== + +"@rolldown/binding-openharmony-arm64@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz#f7c75fa913fc20884d26a7d488d4f5c597cd71c0" + integrity sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw== + +"@rolldown/binding-wasm32-wasi@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz#c379581947787081df363ea106140fcd5fec252d" + integrity sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA== + dependencies: + "@emnapi/core" "1.11.1" + "@emnapi/runtime" "1.11.1" + "@napi-rs/wasm-runtime" "^1.1.6" + +"@rolldown/binding-win32-arm64-msvc@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz#f23c88694f7a729a12f395024aee38df80a16ba3" + integrity sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw== + "@rolldown/binding-win32-x64-msvc@1.1.5": version "1.1.5" resolved "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz" @@ -349,6 +452,13 @@ resolved "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz" integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA== +"@tybys/wasm-util@^0.10.3": + version "0.10.3" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" + integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== + dependencies: + tslib "^2.4.0" + "@types/chai@^5.2.2": version "5.2.3" resolved "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz" @@ -377,7 +487,7 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/node@^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node@^20.19.0 || >=22.12.0", "@types/node@^26.1.1": +"@types/node@^26.1.1": version "26.1.1" resolved "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz" integrity sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw== @@ -389,7 +499,7 @@ resolved "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz" integrity sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg== -"@types/react@*", "@types/react@^19.2.17", "@types/react@>=18.0.0": +"@types/react@^19.2.17": version "19.2.17" resolved "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz" integrity sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw== @@ -401,7 +511,7 @@ resolved "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz" integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA== -"@types/three@^0.182.0", "@types/three@>=0.134.0": +"@types/three@^0.182.0": version "0.182.0" resolved "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz" integrity sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q== @@ -433,7 +543,7 @@ natural-compare "^1.4.0" ts-api-utils "^2.5.0" -"@typescript-eslint/parser@^8.64.0", "@typescript-eslint/parser@^8.65.0": +"@typescript-eslint/parser@^8.64.0": version "8.65.0" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz" integrity sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA== @@ -461,7 +571,7 @@ "@typescript-eslint/types" "8.65.0" "@typescript-eslint/visitor-keys" "8.65.0" -"@typescript-eslint/tsconfig-utils@^8.65.0", "@typescript-eslint/tsconfig-utils@8.65.0": +"@typescript-eslint/tsconfig-utils@8.65.0", "@typescript-eslint/tsconfig-utils@^8.65.0": version "8.65.0" resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz" integrity sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg== @@ -477,7 +587,7 @@ debug "^4.4.3" ts-api-utils "^2.5.0" -"@typescript-eslint/types@^8.65.0", "@typescript-eslint/types@8.65.0": +"@typescript-eslint/types@8.65.0", "@typescript-eslint/types@^8.65.0": version "8.65.0" resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz" integrity sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg== @@ -585,7 +695,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0: +acorn@^8.15.0: version "8.17.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz" integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg== @@ -755,7 +865,7 @@ brace-expansion@^5.0.5: dependencies: balanced-match "^4.0.2" -browserslist@^4.24.0, "browserslist@>= 4.21.0": +browserslist@^4.24.0: version "4.28.7" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz" integrity sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw== @@ -1102,7 +1212,7 @@ escape-string-regexp@^4.0.0: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -eslint-config-prettier@^10.1.8, "eslint-config-prettier@>= 7.0.0 <10.0.0 || >=10.1.0": +eslint-config-prettier@^10.1.8: version "10.1.8" resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz" integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== @@ -1128,7 +1238,7 @@ eslint-module-utils@^2.12.1: dependencies: debug "^3.2.7" -eslint-plugin-import@^2.32.0, eslint-plugin-import@>=1.4.0: +eslint-plugin-import@^2.32.0: version "2.32.0" resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz" integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== @@ -1219,7 +1329,7 @@ eslint-visitor-keys@^5.0.0: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz" integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== -"eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", "eslint@^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7", "eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.40 || 9 || 10", "eslint@^8.57.0 || ^9.0.0 || ^10.0.0", eslint@^9.0.0, eslint@>=7.0.0, eslint@>=8.0.0: +eslint@^9.0.0: version "9.39.5" resolved "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz" integrity sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw== @@ -1369,6 +1479,11 @@ for-each@^0.3.3, for-each@^0.3.5: dependencies: is-callable "^1.2.7" +fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" @@ -1842,6 +1957,56 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lightningcss-android-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907" + integrity sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== + +lightningcss-darwin-arm64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz#c0f2c31c0bfd19fa4dd3f18e957a1f1a152097d6" + integrity sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== + +lightningcss-darwin-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz#cb0705965acb538c6683949ce6925fb3cdf7c361" + integrity sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== + +lightningcss-freebsd-x64@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz#763538828b26bab2680dadafcc84ee78b0eb502b" + integrity sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== + +lightningcss-linux-arm-gnueabihf@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz#6862e3176a331aedbdec1ed352b4d7d0dd0784de" + integrity sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== + +lightningcss-linux-arm64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz#c6a3a2ed15141daf6bdc2628930f8e39bdf473aa" + integrity sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== + +lightningcss-linux-arm64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz#7fa1334971fc82845f9827df6ef8a0b20914bac6" + integrity sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== + +lightningcss-linux-x64-gnu@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz#8b927862ea8c2bbc6831a46509244b50d9936e55" + integrity sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== + +lightningcss-linux-x64-musl@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz#0c525bb077dfd94404c059cfe42dad797e96aeaf" + integrity sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== + +lightningcss-win32-arm64-msvc@1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz#850ee1103dac989cfab50e3ac22d1a69e394e63d" + integrity sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== + lightningcss-win32-x64-msvc@1.33.0: version "1.33.0" resolved "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz" @@ -2107,7 +2272,7 @@ picocolors@^1.1.1: resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -"picomatch@^3 || ^4", picomatch@^4.0.3, picomatch@^4.0.4, picomatch@^4.0.5: +picomatch@^4.0.3, picomatch@^4.0.4, picomatch@^4.0.5: version "4.0.5" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz" integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== @@ -2126,7 +2291,7 @@ postcss@^8.5.17: picocolors "^1.1.1" source-map-js "^1.2.1" -postprocessing@^6.39.3, postprocessing@>=6.30.0: +postprocessing@^6.39.3: version "6.39.3" resolved "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.3.tgz" integrity sha512-h5H1iuN96aRkU06CzJ8d/FqFe3Qs2bI7LiGHTzOI5T5mQqjzovi2t1EkRHb8qkHgoD9cTJDb4uA30AkSxsFB7A== @@ -2143,7 +2308,7 @@ prettier-linter-helpers@^1.0.1: dependencies: fast-diff "^1.1.2" -prettier@^3.9.5, prettier@>=3.0.0: +prettier@^3.9.5: version "3.9.6" resolved "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz" integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g== @@ -2172,7 +2337,7 @@ react-use-measure@^2.1.7: resolved "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz" integrity sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg== -"react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", react@^19.0.0, react@^19.2.7, react@>=16.13, react@>=17.0, react@>=18.0.0, "react@>=19 <19.3": +react@^19.2.7: version "19.2.8" resolved "https://registry.npmjs.org/react/-/react-19.2.8.tgz" integrity sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw== @@ -2492,7 +2657,7 @@ synckit@^0.11.13: dependencies: "@pkgr/core" "^0.3.6" -three@^0.182.0, "three@>= 0.168.0 < 0.186.0", three@>=0.134.0, three@>=0.137, three@>=0.156: +three@^0.182.0: version "0.182.0" resolved "https://registry.npmjs.org/three/-/three-0.182.0.tgz" integrity sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ== @@ -2535,6 +2700,11 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@^2.4.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" @@ -2587,7 +2757,7 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.1.0" reflect.getprototypeof "^1.0.10" -typescript@^6.0.0, typescript@>=4.8.4, "typescript@>=4.8.4 <6.1.0": +typescript@^6.0.0: version "6.0.3" resolved "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz" integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== @@ -2622,7 +2792,7 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -use-sync-external-store@^1.4.0, use-sync-external-store@>=1.2.0: +use-sync-external-store@^1.4.0: version "1.6.0" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==