From f01cff9b3a7e7c8dee3b27950aab641adf90fae1 Mon Sep 17 00:00:00 2001 From: kvvasuu Date: Sat, 10 Oct 2026 01:14:38 +0200 Subject: [PATCH] fix(Texture): skip loading when a texture is passed textureSrc is now optional when texture is provided, so a pre-loaded texture no longer triggers useLoader. --- src/effects/Texture.tsx | 14 +++++--- src/tests/Texture.test.tsx | 57 ++++++++++++++++++++++++++++++++ src/tests/effects.smoke.test.tsx | 8 +++-- 3 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 src/tests/Texture.test.tsx diff --git a/src/effects/Texture.tsx b/src/effects/Texture.tsx index e731d2a..2773a40 100644 --- a/src/effects/Texture.tsx +++ b/src/effects/Texture.tsx @@ -2,21 +2,25 @@ import { useLoader } from '@react-three/fiber' import { TextureEffect } from 'postprocessing' import type { Ref } from 'react' import { useLayoutEffect } from 'react' -import { RepeatWrapping, SRGBColorSpace, TextureLoader } from 'three' +import { RepeatWrapping, SRGBColorSpace, TextureLoader, type Texture as ThreeTexture } from 'three' import { createEffectComponent, type EffectOptions } from '../createEffectComponent' const TextureImpl = /* @__PURE__ */ createEffectComponent>( TextureEffect ) -export type TextureProps = EffectOptions & { - textureSrc: string +export type TextureProps = Omit, 'texture'> & { /** opacity of provided texture */ opacity?: number ref?: Ref -} +} & ({ textureSrc: string; texture?: never } | { texture: ThreeTexture; textureSrc?: never }) export function Texture({ textureSrc, texture, opacity = 1, ...props }: TextureProps) { + if (texture) return + return +} + +function LoadedTexture({ textureSrc, ...props }: TextureProps & { textureSrc: string }) { const t = useLoader(TextureLoader, textureSrc) useLayoutEffect(() => { @@ -24,5 +28,5 @@ export function Texture({ textureSrc, texture, opacity = 1, ...props }: TextureP t.wrapS = t.wrapT = RepeatWrapping }, [t]) - return + return } diff --git a/src/tests/Texture.test.tsx b/src/tests/Texture.test.tsx new file mode 100644 index 0000000..0c47f6f --- /dev/null +++ b/src/tests/Texture.test.tsx @@ -0,0 +1,57 @@ +import { TextureEffect } from 'postprocessing' +import * as React from 'react' +import * as THREE from 'three' +import { describe, expect, it, vi } from 'vitest' +import { EffectComposer } from '../EffectComposer' +import { Texture } from '../effects/Texture' +import { flush, root } from './test-utils' + +describe('Texture', () => { + it('uses a passed texture without loading anything', async () => { + const loadSpy = vi.spyOn(THREE.TextureLoader.prototype, 'load') + const ref = React.createRef() + const texture = new THREE.Texture() + + await React.act(async () => + root.render( + + + + ) + ) + await flush() + + expect(loadSpy).not.toHaveBeenCalled() + expect(ref.current!.texture).toBe(texture) + // A user-provided texture is not mutated + expect(texture.colorSpace).toBe(THREE.NoColorSpace) + + await React.act(async () => root.render(null)) + loadSpy.mockRestore() + }) + + it('swaps a passed texture live, without reconstructing the effect', async () => { + const ref = React.createRef() + const a = new THREE.Texture() + const b = new THREE.Texture() + + const render = (texture: THREE.Texture) => + root.render( + + + + ) + + await React.act(async () => render(a)) + await flush() + const first = ref.current + + await React.act(async () => render(b)) + await flush() + + expect(ref.current).toBe(first) + expect(ref.current!.texture).toBe(b) + + await React.act(async () => root.render(null)) + }) +}) diff --git a/src/tests/effects.smoke.test.tsx b/src/tests/effects.smoke.test.tsx index 1d098f8..e006598 100644 --- a/src/tests/effects.smoke.test.tsx +++ b/src/tests/effects.smoke.test.tsx @@ -54,6 +54,7 @@ import { Sepia } from '../effects/Sepia' import { ShockWave } from '../effects/ShockWave' import { SMAA } from '../effects/SMAA' import { SSAO } from '../effects/SSAO' +import { Texture } from '../effects/Texture' import { TiltShift } from '../effects/TiltShift' import { TiltShift2 } from '../effects/TiltShift2' import { ToneMapping } from '../effects/ToneMapping' @@ -125,6 +126,11 @@ const SMOKE_CASES: SmokeCase[] = [ composerProps: { enableNormalPass: true }, effect: (ref) => , }, + { + file: 'Texture.tsx', + label: 'Texture (with a passed texture)', + effect: (ref) => , + }, { file: 'TiltShift.tsx', label: 'TiltShift', effect: (ref) => }, { file: 'TiltShift2.tsx', label: 'TiltShift2', effect: (ref) => }, { file: 'ToneMapping.tsx', label: 'ToneMapping', effect: (ref) => }, @@ -133,8 +139,6 @@ const SMOKE_CASES: SmokeCase[] = [ ] const EXCLUDED: Record = { - 'Texture.tsx': - 'loads via useLoader(TextureLoader, textureSrc) — needs a real image decode pipeline this test environment cannot provide. Verify manually.', 'ASCII.tsx': "constructs its character atlas via document.createElement('canvas') — this project's vitest config runs the node test environment (no jsdom/document). Verify manually.", }