diff --git a/README.md b/README.md index 8a38a9c..c1bc113 100644 --- a/README.md +++ b/README.md @@ -396,33 +396,50 @@ function RotatingGroup() { ### Gradient Fill -```jsx -function () { - const [gradient, setGradient] = useState(null); +Use a gradient's render function to bind the same resource to one or more shapes, +without callback refs or React state. Stops follow JSX order and support updates, +removal, fragments, and custom components. Gradients do not create a scene Group; +shapes remain children of the surrounding Canvas or Group. - const updateRef = useMemo((ref) => { - if (ref) { - setGradient(ref); - } - }, [setGradient]); +```jsx +import { Canvas, LinearGradient, Stop, Rectangle, Circle } from 'react-two.js' +function GradientScene() { return ( - - + + {(gradient) => ( + <> + + + + + + )} + - ); + ) } ``` +`RadialGradient` uses the same children API, with `x`, `y`, `radius`, `focalX`, +and `focalY` options. Both gradients forward refs to their Two.js instance. +`Stop` forwards a `RefStop` and accepts `offset` (default 0), `color` (default +`#fff`), and `opacity` (default 1); removing a prop restores that default. + +Existing `stops={[new Two.Stop(0, 'red'), new Two.Stop(1, 'blue')]}` arrays remain +supported. If combined with JSX stops, array stops come first. Array entries must +be `Two.Stop` instances; plain objects are not supported. Supplied array stops +are borrowed: cleanup detaches them without disposing their other listeners. + +React owns gradients created by these components. On unmount, their last stop +values remain usable while another mounted react-two.js shape still uses the +gradient as fill or stroke. Once the last consumer releases it, cleanup removes +SVG definitions, clears the renderer effect cache, and detaches stop listeners. +Cleanup is deferred until the commit finishes, including Strict Mode replay. +Imperative consumers outside React must manage their own gradient resources; +do not retain an owned gradient after its React owner and consumers unmount. + ### Loading SVG Files Load external SVG files or use inline SVG markup with the `` component: diff --git a/docs/property-matrix.md b/docs/property-matrix.md index 17799fd..633389e 100644 --- a/docs/property-matrix.md +++ b/docs/property-matrix.md @@ -30,8 +30,9 @@ Where ergonomic improvements are possible without breaking Two.js parity (for ex | `` | `Two.Image` | All `` props, plus `texture`, `mode`, `src` | `origin`, `mask`, `clip`, `strokeAttenuation` | | `` | `Two.Sprite` | All `` props, plus `texture`, `columns`, `rows`, `frameRate`, `index`, `firstFrame`, `lastFrame`, `loop`, `src`, `autoPlay` | `origin`, `Two.Texture` as `src`, `mask`, `clip`, `strokeAttenuation` | | `` | `Two.ImageSequence` | All `` props, plus `textures`, `frameRate`, `index`, `firstFrame`, `lastFrame`, `loop`, `src`, `autoPlay` | `origin`, `mask`, `clip`, `strokeAttenuation` | -| `` | `Two.LinearGradient` | `id`, `className`, `spread`, `units`, `stops`, `left`, `right`, `x1`, `y1`, `x2`, `y2` | Full declarative parity | -| `` | `Two.RadialGradient` | `id`, `className`, `spread`, `units`, `stops`, `center`, `radius`, `focal`, `x`, `y`, `focalX`, `focalY` | Full declarative parity | +| `` | `Two.LinearGradient` | `id`, `className`, `spread`, `units`, `stops`, `left`, `right`, `x1`, `y1`, `x2`, `y2` | JSX stops and render-function binding | +| `` | `Two.RadialGradient` | `id`, `className`, `spread`, `units`, `stops`, `center`, `radius`, `focal`, `x`, `y`, `focalX`, `focalY` | JSX stops and render-function binding | +| `` | `Two.Stop` | `offset`, `color`, `opacity` | JSX order, updates, and gradient attachment | | `` | `Two.Texture` | `id`, `className`, `src`, `loaded`, `repeat`, `scale`, `offset`, `image` | Full declarative parity | --- diff --git a/lib/GradientChildren.tsx b/lib/GradientChildren.tsx new file mode 100644 index 0000000..c977a98 --- /dev/null +++ b/lib/GradientChildren.tsx @@ -0,0 +1,57 @@ +import React, { useLayoutEffect, useMemo } from 'react'; +import { GradientContext, type GradientScope } from './GradientContext'; +import { ownGradient, type GradientResource } from './gradientResources'; + +export type GradientChildNodes = React.ReactNode | ((gradient: T) => React.ReactNode); + +export function GradientChildren({ gradient, children, stops }: { + gradient: T; + children?: GradientChildNodes; + stops?: T['stops']; +}) { + const baseStops = React.useRef(stops); + const boundary = React.useRef(null); + const scope = useMemo(() => { + const entries = new Map(); + const value: GradientScope = { + gradient, entries, active: true, + sync: () => { + if (!value.active) return; + const ordered = [...entries].sort(([a], [b]) => + a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 + ).map(([, stop]) => stop); + // Keep borrowed stops first, followed by JSX stops in document order. + const next = [...(baseStops.current ?? []), ...ordered]; + if (!gradient.stops || next.length !== gradient.stops.length || next.some((stop, i) => stop !== gradient.stops[i])) { + gradient.stops = next; + // Two.js 0.8.24 only reattaches SVG stops when the child count + // changes. Invalidate its children for equal-length reorder/replacement. + const renderer = (gradient as unknown as { _renderer: { elem?: Element } })._renderer; + renderer.elem?.replaceChildren(); + } + }, + }; + return value; + }, [gradient]); + useLayoutEffect(() => { + scope.active = true; + const release = ownGradient(gradient); + // A memoized Stop can move when a nested component updates without this + // scope rendering. Observe the hidden React DOM as a fallback in that case. + const observer = new MutationObserver(() => scope.sync()); + observer.observe(boundary.current!.parentNode!, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + scope.active = false; + release(); + }; + }, [scope, gradient]); + useLayoutEffect(() => { + baseStops.current = stops; + scope.sync(); + }); + return + + {typeof children === 'function' ? children(gradient) : children} + ; +} diff --git a/lib/GradientContext.ts b/lib/GradientContext.ts new file mode 100644 index 0000000..c933448 --- /dev/null +++ b/lib/GradientContext.ts @@ -0,0 +1,11 @@ +import { createContext } from 'react'; +import type { Stop } from 'two.js/src/effects/stop'; +import type { GradientResource } from './gradientResources'; + +export interface GradientScope { + gradient: GradientResource; + entries: Map; + sync: () => void; + active: boolean; +} +export const GradientContext = createContext(null); diff --git a/lib/LinearGradient.tsx b/lib/LinearGradient.tsx index 86d6b29..2c5b326 100644 --- a/lib/LinearGradient.tsx +++ b/lib/LinearGradient.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { GradientChildren, type GradientChildNodes } from './GradientChildren'; import Two from 'two.js'; import type { LinearGradient as Instance } from 'two.js/src/effects/linear-gradient'; import { applyVector, GradientProps, type VectorProp } from './Properties'; @@ -6,7 +7,7 @@ import { useTwoObject } from './useTwoObject'; export type LinearGradientProps = GradientProps | 'left' | 'right'; -type ComponentProps = React.PropsWithChildren< +type ComponentProps = { [K in Extract]?: K extends 'left' | 'right' ? VectorProp @@ -18,17 +19,16 @@ type ComponentProps = React.PropsWithChildren< y2?: number; left?: VectorProp; right?: VectorProp; - } ->; + } & { children?: GradientChildNodes }; export type RefLinearGradient = Instance; export const LinearGradient = React.forwardRef( (props, forwardedRef) => { - useTwoObject(props as Record, forwardedRef, { + const { instance } = useTwoObject(props as Record, forwardedRef, { factory: () => new Two.LinearGradient(), isSceneObject: false, - specialProps: ['x1', 'y1', 'x2', 'y2', 'left', 'right'], + specialProps: ['stops', 'x1', 'y1', 'x2', 'y2', 'left', 'right'], applySpecialProps: (gradient, currentProps, changed, removed) => { const p = currentProps as unknown as ComponentProps; const grad = gradient as unknown as Instance; @@ -73,6 +73,6 @@ export const LinearGradient = React.forwardRef( }, }); - return null; + return {props.children}; } ); diff --git a/lib/RadialGradient.tsx b/lib/RadialGradient.tsx index 4c638bd..57a946a 100644 --- a/lib/RadialGradient.tsx +++ b/lib/RadialGradient.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { GradientChildren, type GradientChildNodes } from './GradientChildren'; import Two from 'two.js'; import type { RadialGradient as Instance } from 'two.js/src/effects/radial-gradient'; import { applyVector, GradientProps, type VectorProp } from './Properties'; @@ -6,7 +7,7 @@ import { useTwoObject } from './useTwoObject'; export type RadialGradientProps = GradientProps | 'center' | 'radius' | 'focal'; -type ComponentProps = React.PropsWithChildren< +type ComponentProps = { [K in Extract]?: K extends 'center' | 'focal' ? VectorProp @@ -18,17 +19,16 @@ type ComponentProps = React.PropsWithChildren< focalY?: number; center?: VectorProp; focal?: VectorProp; - } ->; + } & { children?: GradientChildNodes }; export type RefRadialGradient = Instance; -export const RadialGradient = React.forwardRef( +export const RadialGradient = React.forwardRef( (props, forwardedRef) => { - useTwoObject(props as Record, forwardedRef, { + const { instance } = useTwoObject(props as Record, forwardedRef, { factory: () => new Two.RadialGradient(), isSceneObject: false, - specialProps: ['x', 'y', 'focalX', 'focalY', 'center', 'focal'], + specialProps: ['stops', 'x', 'y', 'focalX', 'focalY', 'center', 'focal'], applySpecialProps: (gradient, currentProps, changed, removed) => { const p = currentProps as unknown as ComponentProps; const grad = gradient as unknown as Instance; @@ -73,6 +73,6 @@ export const RadialGradient = React.forwardRef( }, }); - return null; + return {props.children}; } ); diff --git a/lib/Stop.tsx b/lib/Stop.tsx new file mode 100644 index 0000000..fc578ef --- /dev/null +++ b/lib/Stop.tsx @@ -0,0 +1,39 @@ +import React, { useContext, useImperativeHandle, useLayoutEffect, useMemo, useRef } from 'react'; +import Two from 'two.js'; +import type { Stop as Instance } from 'two.js/src/effects/stop'; +import { GradientContext } from './GradientContext'; + +export interface StopProps { + offset?: number; + color?: string; + opacity?: number; +} +export type RefStop = Instance; + +/** A gradient stop, ordered by its position in the React tree. */ +export const Stop = React.forwardRef((props, ref) => { + const scope = useContext(GradientContext); + if (!scope) throw new Error('Stop must be rendered inside LinearGradient or RadialGradient.'); + const stop = useMemo(() => new Two.Stop(0, '#fff', 1), []); + const marker = useRef(null); + useImperativeHandle(ref, () => stop, [stop]); + useLayoutEffect(() => { + stop.offset = props.offset ?? 0; + stop.color = props.color ?? '#fff'; + stop.opacity = props.opacity ?? 1; + }, [stop, props.offset, props.color, props.opacity]); + useLayoutEffect(() => { + const element = marker.current!; + scope.entries.set(element, stop); + scope.sync(); + return () => { + scope.entries.delete(element); + scope.sync(); + }; + }, [scope, stop]); + useLayoutEffect(() => { scope.sync(); }); + // Canvas already renders React children in a hidden container. This inert + // marker also preserves order through fragments, memo and custom components. + return ; +}); +Stop.displayName = 'Stop'; diff --git a/lib/gradientResources.ts b/lib/gradientResources.ts new file mode 100644 index 0000000..730c5b5 --- /dev/null +++ b/lib/gradientResources.ts @@ -0,0 +1,44 @@ +import type { LinearGradient } from 'two.js/src/effects/linear-gradient'; +import type { RadialGradient } from 'two.js/src/effects/radial-gradient'; + +export type GradientResource = LinearGradient | RadialGradient; + +// Inspect committed objects rather than prop values: Group setters can cascade +// paints to children, and fills can also be assigned through forwarded refs. +const consumers = new Set(); +const retired = new Set(); +let scheduled = false; + +export function collectGradients() { + if (scheduled || retired.size === 0) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + for (const gradient of retired) { + const used = [...consumers].some((object) => { + const paint = object as { fill?: unknown; stroke?: unknown }; + return paint.fill === gradient || paint.stroke === gradient; + }); + if (used) continue; + retired.delete(gradient); + // Two.js dispose assumes its SVG element still has a parent. A Canvas + // may already have removed its document during the same React commit. + const renderer = (gradient as unknown as { _renderer: { elem?: Element; effect?: unknown } })._renderer; + renderer.elem?.remove(); + delete renderer.elem; + gradient.dispose(); + gradient.stops = []; + gradient.unbind(); + } + }); +} + +export function trackGradientConsumer(object: object) { + consumers.add(object); + return () => { consumers.delete(object); collectGradients(); }; +} + +export function ownGradient(gradient: GradientResource) { + retired.delete(gradient); + return () => { retired.add(gradient); collectGradients(); }; +} diff --git a/lib/main.ts b/lib/main.ts index e55e796..f43d039 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -69,3 +69,5 @@ export { } from './propertyMatrix'; + +export { Stop, type StopProps, type RefStop } from './Stop'; diff --git a/lib/propertyMatrix.ts b/lib/propertyMatrix.ts index c855de3..b90cb06 100644 --- a/lib/propertyMatrix.ts +++ b/lib/propertyMatrix.ts @@ -877,6 +877,12 @@ export const PROPERTY_MATRIX: Record = { ], }, + Stop: { + component: 'Stop', + twoClass: 'Two.Stop', + supportedProps: ['offset', 'color', 'opacity'], + omittedProps: [{ property: 'parent', reason: 'managed-by-react', description: 'The surrounding gradient owns stop attachment and JSX order.' }], + }, Texture: { component: 'Texture', twoClass: 'Two.Texture', diff --git a/lib/useTwoObject.ts b/lib/useTwoObject.ts index c9c4f5e..54c7974 100644 --- a/lib/useTwoObject.ts +++ b/lib/useTwoObject.ts @@ -1,3 +1,4 @@ +import { collectGradients, trackGradientConsumer } from './gradientResources'; import React, { useCallback, useContext, @@ -253,6 +254,13 @@ export function useTwoObject< const instance = instanceRef.current!; + useLayoutEffect(() => { + if (isSceneObject && typeof instance === 'object' && instance !== null) { + return trackGradientConsumer(instance); + } + }, [instance, isSceneObject]); + useLayoutEffect(() => { collectGradients(); }); + // Forward ref useImperativeHandle(forwardedRef, () => instance, [instance]); diff --git a/tests/browser/gradients.html b/tests/browser/gradients.html new file mode 100644 index 0000000..e83618b --- /dev/null +++ b/tests/browser/gradients.html @@ -0,0 +1 @@ +Gradient lifecycle regression
Running…
diff --git a/tests/browser/gradients.tsx b/tests/browser/gradients.tsx new file mode 100644 index 0000000..4e4a3c7 --- /dev/null +++ b/tests/browser/gradients.tsx @@ -0,0 +1,76 @@ +import { createRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { flushSync } from 'react-dom'; +import Two from 'two.js'; +import { Canvas, LinearGradient, RadialGradient, Rectangle, Stop, useTwo } from '../../lib/main'; + +const output = document.querySelector('#results')!; +const messages: string[] = []; +let two: Two | null = null; +// eslint-disable-next-line react-refresh/only-export-components +function Probe() { two = useTwo().two; return null; } +function assert(value: unknown, message: string): asserts value { + if (!value) throw new Error(message); +} +const tick = () => new Promise(resolve => requestAnimationFrame(() => resolve())); + +async function run() { + for (const type of ['svg', 'canvas', 'webgl'] as const) { + for (const kind of ['linear', 'radial'] as const) { + const container = document.createElement('div'); + document.querySelector('#test-root')!.append(container); + two = null; + const root = createRoot(container); + const ref = createRef(); + const rect = createRef(); + const Gradient = kind === 'linear' ? LinearGradient : RadialGradient; + const draw = (reverse: boolean) => + + {gradient => <> + {(reverse ? ['blue', 'red'] : ['red', 'blue']).map((color, i) => )} + + } + + ; + flushSync(() => root.render(draw(false))); + for (let attempt = 0; attempt < 30 && !two; attempt++) await tick(); + assert(two, `${type}: mounted`); + two.update(); + const gradient = ref.current!; + assert(gradient.stops.length === 2 && rect.current!.fill === gradient, `${type}/${kind}: binding`); + const element = two.renderer.domElement; + // Sample a real renderer, including GPU output through a 2D copy. + const sample = () => { + const copy = document.createElement('canvas'); copy.width = copy.height = 100; + const ctx = copy.getContext('2d')!; + ctx.drawImage(element as HTMLCanvasElement, 0, 0); + return ctx.getImageData(kind === 'linear' ? 10 : 50, 50, 1, 1).data; + }; + if (type === 'svg') { + const stops = element.querySelectorAll('stop'); + assert(stops.length === 2 && stops[0].getAttribute('stop-color') === 'red', 'SVG stop colors'); + } else { + const pixel = sample(); + assert(pixel[0] > pixel[2] + 100 && pixel[3] > 200, `${type}/${kind}: red pixel ${pixel}`); + } + flushSync(() => root.render(draw(true))); + two.update(); + assert(ref.current === gradient && gradient.stops[0].color === 'blue', `${type}/${kind}: keyed reorder`); + if (type !== 'svg') { + const pixel = sample(); + assert(pixel[2] > pixel[0] + 100 && pixel[3] > 200, `${type}/${kind}: blue pixel ${pixel}`); + } else { + assert(element.querySelector('stop')?.getAttribute('stop-color') === 'blue', 'SVG updated stop color'); + } + flushSync(() => root.unmount()); + await Promise.resolve(); + assert(gradient.stops.length === 0, `${type}/${kind}: ownership cleanup`); + assert(!element.querySelector('linearGradient, radialGradient'), `${type}/${kind}: definitions removed`); + container.remove(); + messages.push(`PASS ${type}/${kind}: fill binding, real rendered colors, keyed updates, cleanup`); + output.textContent = messages.join('\n'); + } + } + output.textContent += '\nALL 6 GRADIENT RENDERER CHECKS PASSED'; +} +void run().catch(error => { output.textContent = `${messages.join('\n')}\nFAIL ${String(error)}`; }); diff --git a/tests/gradients.test.tsx b/tests/gradients.test.tsx new file mode 100644 index 0000000..2b7d465 --- /dev/null +++ b/tests/gradients.test.tsx @@ -0,0 +1,196 @@ +import React, { createRef, memo, useState } from 'react'; +import { act, render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import Two from 'two.js'; +import { Canvas, Circle, Group, LinearGradient, RadialGradient, Rectangle, Stop, useTwo, type RefStop } from '../lib/main'; + +const settle = () => act(async () => { await Promise.resolve(); }); + +for (const Gradient of [LinearGradient, RadialGradient]) { + describe(Gradient === LinearGradient ? 'LinearGradient composition' : 'RadialGradient composition', () => { + it('binds fill and stroke, forwards stops, updates and resets stop props', () => { + const ref = createRef(); + const shape = createRef(); + const second = createRef(); + const draw = (custom: boolean) => {gradient => <> + + + + }; + const view = render(draw(true)); + const gradient = shape.current!.fill as Two.LinearGradient; + expect(gradient.stops).toEqual([ref.current]); + expect(second.current!.stroke).toBe(gradient); + expect([ref.current!.offset, ref.current!.color, ref.current!.opacity]).toEqual([0.3, 'red', 0.4]); + view.rerender(draw(false)); + expect(shape.current!.fill).toBe(gradient); + expect([ref.current!.offset, ref.current!.color, ref.current!.opacity]).toEqual([0, '#fff', 1]); + }); + + it('reorders keyed memo stops across fragments and removes/adds stops', () => { + const ref = createRef(); + const Item = memo(({ color }: { color: string }) => ); + const draw = (colors: string[]) => + <>{colors.map(color => )} + ; + const view = render(draw(['red', 'blue', 'green'])); + const blue = ref.current!.stops[1]; + view.rerender(draw(['green', 'blue', 'red'])); + expect(ref.current!.stops.map(s => s.color)).toEqual(['green', 'blue', 'red']); + expect(ref.current!.stops[1]).toBe(blue); + view.rerender(draw(['blue', 'yellow'])); + expect(ref.current!.stops.map(s => s.color)).toEqual(['blue', 'yellow']); + }); + + it('handles locally updated children without rerendering the resource', () => { + const ref = createRef(); + let toggle!: () => void; + function Stops() { + const [visible, set] = useState(true); + toggle = () => set(v => !v); + return <>{visible && }; + } + render(); + expect(ref.current!.stops.length).toBe(2); + act(() => toggle()); + expect(ref.current!.stops.map(s => s.color)).toEqual(['red']); + act(() => toggle()); + expect(ref.current!.stops.map(s => s.color)).toEqual(['red', 'blue']); + }); + + it('preserves borrowed array stops and replaces/removes the array', async () => { + const ref = createRef(); + const borrowed = new Two.Stop(0, 'red'); + const listener = vi.fn(); + borrowed.bind('change', listener); + const draw = (stops?: Two.Stop[]) => + + ; + const view = render(draw([borrowed])); + expect(ref.current!.stops.map(s => s.color)).toEqual(['red', 'blue']); + view.rerender(draw([new Two.Stop(1, 'green')])); + expect(ref.current!.stops.map(s => s.color)).toEqual(['green', 'blue']); + view.rerender(draw()); + expect(ref.current!.stops.map(s => s.color)).toEqual(['blue']); + view.unmount(); await settle(); + borrowed.trigger('change'); + expect(listener).toHaveBeenCalledOnce(); + }); + }); +} + +it('retains shared stops after owner unmount and disposes after last consumer releases', async () => { + let gradient!: Two.LinearGradient; + const shape = createRef(); + function App({ owner, use }: { owner: boolean; use: boolean }) { + return + {owner && {g => { gradient = g; return <>; }}} + + ; + } + const view = render(); + view.rerender(); + const dispose = vi.spyOn(gradient, 'dispose'); + view.rerender(); + await settle(); + expect(gradient.stops.map(s => s.color)).toEqual(['red', 'blue']); + expect(dispose).not.toHaveBeenCalled(); + view.rerender(); + await settle(); + expect(dispose).toHaveBeenCalledOnce(); + expect(gradient.stops).toHaveLength(0); +}); + +it('survives Strict Mode replay and cleans SVG definitions on unmount', async () => { + const ref = createRef(); + let two!: Two; + function Probe() { two = useTwo().two!; return null; } + const view = render( + {gradient => <> + } + ); + await settle(); + const gradient = ref.current!; + expect(gradient.stops).toHaveLength(2); + act(() => { two.update(); }); + const definition = two.renderer.domElement.querySelector('linearGradient'); + expect(definition).not.toBeNull(); + expect(definition!.children).toHaveLength(2); + const dispose = vi.spyOn(gradient, 'dispose'); + view.unmount(); await settle(); + expect(dispose).toHaveBeenCalledOnce(); + expect(definition!.parentNode).toBeNull(); +}); + +it('updates actual SVG stop order for equal-length reorder and replacement', () => { + let two!: Two; + function Probe() { two = useTwo().two!; return null; } + const draw = (colors: string[]) => + {gradient => <>{colors.map((color, i) => )} + } + ; + const view = render(draw(['red', 'blue'])); + const colors = () => { + act(() => { two.update(); }); + return [...two.renderer.domElement.querySelectorAll('stop')].map(s => s.getAttribute('stop-color')); + }; + expect(colors()).toEqual(['red', 'blue']); + view.rerender(draw(['blue', 'red'])); + expect(colors()).toEqual(['blue', 'red']); + view.rerender(draw(['green', 'yellow'])); + expect(colors()).toEqual(['green', 'yellow']); +}); + +it('replaces a resource without replacing the shape and retains it until both paint consumers release', async () => { + let current!: Two.LinearGradient; + const shape = createRef(); + const other = createRef(); + function App({ resourceKey, owner, fill, stroke }: { resourceKey: string; owner: boolean; fill: boolean; stroke: boolean }) { + return + {owner && {gradient => { current = gradient; return ; }}} + + + ; + } + const view = render(); + const old = current; + const disposeOld = vi.spyOn(old, 'dispose'); + view.rerender(); + const circle = shape.current; + // On replacement, bind the new resource using its render-function API; this + // external-consumer case intentionally updates refs on the following render. + view.rerender(); + const next = current; + expect(next).not.toBe(old); + view.rerender(); + await settle(); + expect(shape.current).toBe(circle); + expect(shape.current!.fill).toBe(next); + expect(disposeOld).toHaveBeenCalledOnce(); + const disposeNext = vi.spyOn(next, 'dispose'); + view.rerender(); + await settle(); + expect(disposeNext).not.toHaveBeenCalled(); + view.rerender(); + await settle(); + expect(disposeNext).not.toHaveBeenCalled(); + view.rerender(); + await settle(); + expect(disposeNext).toHaveBeenCalledOnce(); +}); + +it('observes local keyed reorders even when all Stop children are memoized', async () => { + const ref = createRef(); + let reverse!: () => void; + const MemoStop = memo(Stop); + function Stops() { + const [colors, set] = useState(['red', 'blue']); + reverse = () => set(c => [...c].reverse()); + return <>{colors.map(color => )}; + } + render(); + expect(ref.current!.stops.map(s => s.color)).toEqual(['red', 'blue']); + act(() => reverse()); + await settle(); + expect(ref.current!.stops.map(s => s.color)).toEqual(['blue', 'red']); +}); diff --git a/tests/propertyMatrix.test.tsx b/tests/propertyMatrix.test.tsx index 505763e..1af2e66 100644 --- a/tests/propertyMatrix.test.tsx +++ b/tests/propertyMatrix.test.tsx @@ -174,7 +174,7 @@ describe('Property Matrix & Declarative Coverage (Issue #30)', () => { expect(SUPPORTED_TWO_VERSION).toBe('0.8.24'); }); - it('should contain entries for all 19 exported components', () => { + it('should contain entries for every exported component', () => { const expectedComponents = [ 'Group', 'SVG', @@ -195,6 +195,7 @@ describe('Property Matrix & Declarative Coverage (Issue #30)', () => { 'LinearGradient', 'RadialGradient', 'Texture', + 'Stop', ]; for (const name of expectedComponents) {