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