Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 36 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Canvas width={800} height={600}>
<LinearGradient
ref={updateRef}
x1={0} y1={0}
x2={100} y2={100}
stops={[
{ offset: 0, color: '#FF6B6B' },
{ offset: 1, color: '#4ECDC4' }
]}
/>
<Rectangle width={200} height={200} fill={gradient} />
<LinearGradient x1={-100} y1={0} x2={100} y2={0} units="userSpaceOnUse">
{(gradient) => (
<>
<Stop offset={0} color="#FF6B6B" />
<Stop offset={1} color="#4ECDC4" />
<Rectangle x={200} y={200} width={200} height={200} fill={gradient} />
<Circle x={500} y={200} radius={80} fill="none" stroke={gradient} linewidth={12} />
</>
)}
</LinearGradient>
</Canvas>
);
)
}
```

`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 `<SVG>` component:
Expand Down
5 changes: 3 additions & 2 deletions docs/property-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ Where ergonomic improvements are possible without breaking Two.js parity (for ex
| `<Image>` | `Two.Image` | All `<Rectangle>` props, plus `texture`, `mode`, `src` | `origin`, `mask`, `clip`, `strokeAttenuation` |
| `<Sprite>` | `Two.Sprite` | All `<Rectangle>` props, plus `texture`, `columns`, `rows`, `frameRate`, `index`, `firstFrame`, `lastFrame`, `loop`, `src`, `autoPlay` | `origin`, `Two.Texture` as `src`, `mask`, `clip`, `strokeAttenuation` |
| `<ImageSequence>` | `Two.ImageSequence` | All `<Rectangle>` props, plus `textures`, `frameRate`, `index`, `firstFrame`, `lastFrame`, `loop`, `src`, `autoPlay` | `origin`, `mask`, `clip`, `strokeAttenuation` |
| `<LinearGradient>` | `Two.LinearGradient` | `id`, `className`, `spread`, `units`, `stops`, `left`, `right`, `x1`, `y1`, `x2`, `y2` | Full declarative parity |
| `<RadialGradient>` | `Two.RadialGradient` | `id`, `className`, `spread`, `units`, `stops`, `center`, `radius`, `focal`, `x`, `y`, `focalX`, `focalY` | Full declarative parity |
| `<LinearGradient>` | `Two.LinearGradient` | `id`, `className`, `spread`, `units`, `stops`, `left`, `right`, `x1`, `y1`, `x2`, `y2` | JSX stops and render-function binding |
| `<RadialGradient>` | `Two.RadialGradient` | `id`, `className`, `spread`, `units`, `stops`, `center`, `radius`, `focal`, `x`, `y`, `focalX`, `focalY` | JSX stops and render-function binding |
| `<Stop>` | `Two.Stop` | `offset`, `color`, `opacity` | JSX order, updates, and gradient attachment |
| `<Texture>` | `Two.Texture` | `id`, `className`, `src`, `loaded`, `repeat`, `scale`, `offset`, `image` | Full declarative parity |

---
Expand Down
57 changes: 57 additions & 0 deletions lib/GradientChildren.tsx
Original file line number Diff line number Diff line change
@@ -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<T> = React.ReactNode | ((gradient: T) => React.ReactNode);

export function GradientChildren<T extends GradientResource>({ gradient, children, stops }: {
gradient: T;
children?: GradientChildNodes<T>;
stops?: T['stops'];
}) {
const baseStops = React.useRef(stops);
const boundary = React.useRef<HTMLTemplateElement>(null);
const scope = useMemo<GradientScope>(() => {
const entries = new Map<HTMLElement, T['stops'][number]>();
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 <GradientContext.Provider value={scope}>
<template ref={boundary} />
{typeof children === 'function' ? children(gradient) : children}
</GradientContext.Provider>;
}
11 changes: 11 additions & 0 deletions lib/GradientContext.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement, Stop>;
sync: () => void;
active: boolean;
}
export const GradientContext = createContext<GradientScope | null>(null);
12 changes: 6 additions & 6 deletions lib/LinearGradient.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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';
import { useTwoObject } from './useTwoObject';

export type LinearGradientProps = GradientProps | 'left' | 'right';

type ComponentProps = React.PropsWithChildren<
type ComponentProps =
{
[K in Extract<LinearGradientProps, keyof Instance>]?: K extends 'left' | 'right'
? VectorProp
Expand All @@ -18,17 +19,16 @@ type ComponentProps = React.PropsWithChildren<
y2?: number;
left?: VectorProp;
right?: VectorProp;
}
>;
} & { children?: GradientChildNodes<Instance> };

export type RefLinearGradient = Instance;

export const LinearGradient = React.forwardRef<Instance, ComponentProps>(
(props, forwardedRef) => {
useTwoObject(props as Record<string, unknown>, forwardedRef, {
const { instance } = useTwoObject(props as Record<string, unknown>, 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;
Expand Down Expand Up @@ -73,6 +73,6 @@ export const LinearGradient = React.forwardRef<Instance, ComponentProps>(
},
});

return null;
return <GradientChildren gradient={instance} stops={props.stops}>{props.children}</GradientChildren>;
}
);
14 changes: 7 additions & 7 deletions lib/RadialGradient.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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';
import { useTwoObject } from './useTwoObject';

export type RadialGradientProps = GradientProps | 'center' | 'radius' | 'focal';

type ComponentProps = React.PropsWithChildren<
type ComponentProps =
{
[K in Extract<RadialGradientProps, keyof Instance>]?: K extends 'center' | 'focal'
? VectorProp
Expand All @@ -18,17 +19,16 @@ type ComponentProps = React.PropsWithChildren<
focalY?: number;
center?: VectorProp;
focal?: VectorProp;
}
>;
} & { children?: GradientChildNodes<Instance> };

export type RefRadialGradient = Instance;

export const RadialGradient = React.forwardRef<Instance | null, ComponentProps>(
export const RadialGradient = React.forwardRef<Instance, ComponentProps>(
(props, forwardedRef) => {
useTwoObject(props as Record<string, unknown>, forwardedRef, {
const { instance } = useTwoObject(props as Record<string, unknown>, 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;
Expand Down Expand Up @@ -73,6 +73,6 @@ export const RadialGradient = React.forwardRef<Instance | null, ComponentProps>(
},
});

return null;
return <GradientChildren gradient={instance} stops={props.stops}>{props.children}</GradientChildren>;
}
);
39 changes: 39 additions & 0 deletions lib/Stop.tsx
Original file line number Diff line number Diff line change
@@ -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<Instance, StopProps>((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<HTMLTemplateElement>(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 <template ref={marker} />;
});
Stop.displayName = 'Stop';
44 changes: 44 additions & 0 deletions lib/gradientResources.ts
Original file line number Diff line number Diff line change
@@ -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<object>();
const retired = new Set<GradientResource>();
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(); };
}
2 changes: 2 additions & 0 deletions lib/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@ export {
} from './propertyMatrix';



export { Stop, type StopProps, type RefStop } from './Stop';
6 changes: 6 additions & 0 deletions lib/propertyMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,12 @@ export const PROPERTY_MATRIX: Record<string, ComponentPropertyMatrix> = {
],
},

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',
Expand Down
8 changes: 8 additions & 0 deletions lib/useTwoObject.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { collectGradients, trackGradientConsumer } from './gradientResources';
import React, {
useCallback,
useContext,
Expand Down Expand Up @@ -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]);

Expand Down
1 change: 1 addition & 0 deletions tests/browser/gradients.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="UTF-8"><title>Gradient lifecycle regression</title></head><body><pre id="results">Running…</pre><div id="test-root"></div><script type="module" src="./gradients.tsx"></script></body></html>
Loading
Loading