From db5c92ff00de6b8c2347619094e97d3b5db3af3c Mon Sep 17 00:00:00 2001 From: Jono Brandel Date: Wed, 2 Sep 2026 17:50:40 -0700 Subject: [PATCH 1/4] feat: scene graph reconciliation, discrete prop diffing, and Strict Mode lifecycle (fix #29) --- lib/ArcSegment.tsx | 90 +----- lib/Circle.tsx | 90 +----- lib/Context.ts | 5 + lib/Ellipse.tsx | 92 +----- lib/Group.tsx | 126 +-------- lib/Image.tsx | 98 ++----- lib/ImageSequence.tsx | 100 ++----- lib/Line.tsx | 105 ++----- lib/LinearGradient.tsx | 68 +++-- lib/Path.tsx | 97 +------ lib/Points.tsx | 87 +----- lib/Polygon.tsx | 87 +----- lib/Provider.tsx | 51 +++- lib/RadialGradient.tsx | 55 ++-- lib/Rectangle.tsx | 86 +----- lib/RoundedRectangle.tsx | 92 +----- lib/SVG.tsx | 193 +++---------- lib/Sprite.tsx | 98 +------ lib/Star.tsx | 86 +----- lib/Text.tsx | 87 +----- lib/Texture.tsx | 28 +- lib/main.ts | 4 + lib/reconciliation.ts | 194 +++++++++++++ lib/useTwoObject.ts | 457 ++++++++++++++++++++++++++++++ tests/reconciliation.test.tsx | 510 ++++++++++++++++++++++++++++++++++ 25 files changed, 1510 insertions(+), 1476 deletions(-) create mode 100644 lib/reconciliation.ts create mode 100644 lib/useTwoObject.ts create mode 100644 tests/reconciliation.test.tsx diff --git a/lib/ArcSegment.tsx b/lib/ArcSegment.tsx index cb9f411..df87bde 100644 --- a/lib/ArcSegment.tsx +++ b/lib/ArcSegment.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { ArcSegment as Instance } from 'two.js/src/shapes/arc-segment'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type ArcSegmentProps = | PathProps @@ -13,6 +11,7 @@ type ArcSegmentProps = | 'endAngle' | 'innerRadius' | 'outerRadius'; + type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; @@ -26,84 +25,11 @@ type ComponentProps = React.PropsWithChildren< export type RefArcSegment = Instance; export const ArcSegment = React.forwardRef( - ({ x, y, resolution, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const arcSegment = useMemo( - () => new Two.ArcSegment(0, 0, 0, 0, 0, 0, resolution), - [resolution] - ); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(arcSegment); - return () => { - parent.remove(arcSegment); - }; - } - }, [parent, arcSegment]); - - useEffect(() => { - // Update position - if (typeof x === 'number') arcSegment.translation.x = x; - if (typeof y === 'number') arcSegment.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in arcSegment) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (arcSegment as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, arcSegment, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(arcSegment); - }; - }, [arcSegment, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(arcSegment, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(arcSegment); - } - }, [ - arcSegment, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => arcSegment, [arcSegment]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.ArcSegment(0, 0, 0, 0, 0, 0, p.resolution), + constructionProps: ['resolution'], + }); return <>; } diff --git a/lib/Circle.tsx b/lib/Circle.tsx index 8b48cb0..cb519f0 100644 --- a/lib/Circle.tsx +++ b/lib/Circle.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Circle as Instance } from 'two.js/src/shapes/circle'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type CircleProps = PathProps | 'radius'; type ComponentProps = React.PropsWithChildren< @@ -21,85 +19,11 @@ type ComponentProps = React.PropsWithChildren< export type RefCircle = Instance; export const Circle = React.forwardRef( - ({ x, y, resolution, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const circle = useMemo( - () => new Two.Circle(0, 0, 0, resolution), - [resolution] - ); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - // Update position - if (typeof x === 'number') circle.translation.x = x; - if (typeof y === 'number') circle.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in circle) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (circle as any)[key] = (shapeProps as any)[key]; - } - } - }, [circle, shapeProps, x, y]); - - useEffect(() => { - if (parent) { - parent.add(circle); - - return () => { - parent.remove(circle); - }; - } - }, [parent, circle]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(circle); - }; - }, [circle, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(circle, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(circle); - } - }, [ - circle, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => circle, [circle]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.Circle(0, 0, 0, p.resolution), + constructionProps: ['resolution'], + }); return <>; } diff --git a/lib/Context.ts b/lib/Context.ts index 714c03d..127ff3b 100644 --- a/lib/Context.ts +++ b/lib/Context.ts @@ -17,8 +17,13 @@ export interface TwoCoreContextValue { export interface TwoParentContextValue { parent: Group | null; + attachChild?: (child: Shape | Group) => void; + detachChild?: (child: Shape | Group) => void; + registerChildOrder?: (child: Shape | Group) => void; } +export const ChildSlotContext = createContext(null); + export interface TwoSizeContextValue { width: number; height: number; diff --git a/lib/Ellipse.tsx b/lib/Ellipse.tsx index c7757a3..646296b 100644 --- a/lib/Ellipse.tsx +++ b/lib/Ellipse.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Ellipse as Instance } from 'two.js/src/shapes/ellipse'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type EllipseProps = PathProps | 'width' | 'height'; type ComponentProps = React.PropsWithChildren< @@ -20,86 +18,12 @@ type ComponentProps = React.PropsWithChildren< export type RefEllipse = Instance; -export const Ellipse = React.forwardRef( - ({ x, y, resolution, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const ellipse = useMemo( - () => new Two.Ellipse(0, 0, 0, 0, resolution), - [resolution] - ); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(ellipse); - - return () => { - parent.remove(ellipse); - }; - } - }, [parent, ellipse]); - - useEffect(() => { - // Update position - if (typeof x === 'number') ellipse.translation.x = x; - if (typeof y === 'number') ellipse.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in ellipse) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ellipse as any)[key] = (shapeProps as any)[key]; - } - } - }, [ellipse, x, y, shapeProps]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(ellipse); - }; - }, [ellipse, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(ellipse, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(ellipse); - } - }, [ - ellipse, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => ellipse, [ellipse]); +export const Ellipse = React.forwardRef( + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.Ellipse(0, 0, 0, 0, p.resolution), + constructionProps: ['resolution'], + }); return <>; } diff --git a/lib/Group.tsx b/lib/Group.tsx index f2397a3..012aab7 100644 --- a/lib/Group.tsx +++ b/lib/Group.tsx @@ -1,10 +1,8 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; -import Two from 'two.js'; -import { Context, TwoParentContext, TwoSizeContext, useTwo } from './Context'; - +import React from 'react'; +import { Context, TwoParentContext, TwoSizeContext } from './Context'; import type { Group as Instance } from 'two.js/src/group'; import { ShapeProps, type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoGroup } from './useTwoObject'; type GroupProps = | ShapeProps @@ -19,6 +17,7 @@ type GroupProps = | 'automatic' | 'opacity' | 'visible'; + type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; @@ -31,124 +30,17 @@ type ComponentProps = React.PropsWithChildren< export type RefGroup = Instance; export const Group = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { - two, - parent, - width, - height, - registerEventShape, - unregisterEventShape, - hitTestPoint, - } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const group = useMemo(() => new Two.Group(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(group); - - return () => { - parent.remove(group); - }; - } - }, [parent, group]); - - useEffect(() => { - // Update position - if (typeof x === 'number') group.translation.x = x; - if (typeof y === 'number') group.translation.y = y; - - const args = { ...shapeProps }; - delete args.children; // Allow react to handle children - - // Update other properties (excluding event handlers) - for (const key in args) { - if (key in group) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (group as any)[key] = (args as any)[key]; - } - } - }, [group, x, y, shapeProps]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(group); - }; - }, [group, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(group, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(group); - } - }, [ - group, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => group, [group]); - - const coreValue = useMemo( - () => ({ - two, - registerEventShape, - unregisterEventShape, - hitTestPoint, - }), - [two, registerEventShape, unregisterEventShape, hitTestPoint] - ); - - const parentValue = useMemo( - () => ({ - parent: group, - }), - [group] - ); - - const sizeValue = useMemo( - () => ({ - width, - height, - }), - [width, height] + (props, forwardedRef) => { + const { coreValue, parentValue, sizeValue, renderChildren } = useTwoGroup( + props, + forwardedRef ); return ( - {props.children} + {renderChildren()} diff --git a/lib/Image.tsx b/lib/Image.tsx index 80c9880..4b15d70 100644 --- a/lib/Image.tsx +++ b/lib/Image.tsx @@ -1,12 +1,10 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Image as Instance } from 'two.js/src/effects/image'; import { RectangleProps } from './Rectangle'; import type { Texture } from 'two.js/src/effects/texture'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type ImageProps = RectangleProps | 'mode' | 'texture'; @@ -25,85 +23,25 @@ type ComponentProps = React.PropsWithChildren< export type RefImage = Instance; export const Image = React.forwardRef( - ({ mode, src, texture, x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const image = useMemo(() => new Two.Image(src), [src]); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.Image(p.src), + constructionProps: ['src'], + specialProps: ['mode', 'texture'], + applySpecialProps: (image, currentProps, changed, removed) => { + if ('mode' in changed && currentProps.mode !== undefined) { + image.mode = currentProps.mode; + } else if (removed.includes('mode')) { + image.mode = 'fill'; } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(image); - return () => { - parent.remove(image); - }; - } - }, [parent, image]); - - useEffect(() => { - if (typeof mode !== 'undefined') image.mode = mode; - if (typeof texture !== 'undefined') image.texture = texture; - - // Update position - if (typeof x === 'number') image.translation.x = x; - if (typeof y === 'number') image.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in image) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (image as any)[key] = (shapeProps as any)[key]; + if ('texture' in changed && currentProps.texture !== undefined) { + image.texture = currentProps.texture; + } else if (removed.includes('texture')) { + image.texture = null as unknown as Texture; } - } - }, [image, shapeProps, mode, texture, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(image); - }; - }, [image, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(image, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(image); - } - }, [ - image, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => image, [image]); + }, + }); return <>; } diff --git a/lib/ImageSequence.tsx b/lib/ImageSequence.tsx index 8bb79c0..0cf288f 100644 --- a/lib/ImageSequence.tsx +++ b/lib/ImageSequence.tsx @@ -1,12 +1,10 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { ImageSequence as Instance } from 'two.js/src/effects/image-sequence'; import { RectangleProps } from './Rectangle'; import type { Texture } from 'two.js/src/effects/texture'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type ImageSequenceProps = | RectangleProps @@ -31,88 +29,22 @@ type ComponentProps = React.PropsWithChildren< export type RefImageSequence = Instance; export const ImageSequence = React.forwardRef( - ({ src, x, y, autoPlay, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const imageSequence = useMemo(() => new Two.ImageSequence(src), [src]); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.ImageSequence(p.src), + constructionProps: ['src'], + specialProps: ['autoPlay'], + applySpecialProps: (seq, currentProps) => { + if (currentProps.autoPlay) { + seq.play(); } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(imageSequence); - - return () => { - parent.remove(imageSequence); - }; - } - }, [parent, imageSequence]); - - useEffect(() => { - if (autoPlay) { - imageSequence.play(); - } else { - imageSequence.pause(); - } - - // Update position - if (typeof x === 'number') imageSequence.translation.x = x; - if (typeof y === 'number') imageSequence.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in imageSequence) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (imageSequence as any)[key] = (shapeProps as any)[key]; + seq.pause(); } - } - }, [shapeProps, imageSequence, x, y, autoPlay]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(imageSequence); - }; - }, [imageSequence, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(imageSequence, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(imageSequence); - } - }, [ - imageSequence, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => imageSequence, [imageSequence]); + }, + disposeOwned: (seq) => { + seq.pause(); + }, + }); return <>; } diff --git a/lib/Line.tsx b/lib/Line.tsx index cbdda2b..b743843 100644 --- a/lib/Line.tsx +++ b/lib/Line.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Line as Instance } from 'two.js/src/shapes/line'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type LineProps = PathProps | 'left' | 'right'; type ComponentProps = React.PropsWithChildren< @@ -22,85 +20,36 @@ type ComponentProps = React.PropsWithChildren< export type RefLine = Instance; export const Line = React.forwardRef( - ({ x1, y1, x2, y2, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const line = useMemo(() => new Two.Line(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Line(), + specialProps: ['x1', 'y1', 'x2', 'y2'], + applySpecialProps: (line, currentProps, changed, removed) => { + if ('x1' in changed) { + line.left.x = typeof currentProps.x1 === 'number' ? currentProps.x1 : 0; + } else if (removed.includes('x1')) { + line.left.x = 0; } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(line); - return () => { - parent.remove(line); - }; - } - }, [parent, line]); - - useEffect(() => { - // Update vertices - if (typeof x1 === 'number') line.left.x = x1; - if (typeof y1 === 'number') line.left.y = y1; - - if (typeof x2 === 'number') line.right.x = x2; - if (typeof y2 === 'number') line.right.y = y2; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in line) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (line as any)[key] = (shapeProps as any)[key]; + if ('y1' in changed) { + line.left.y = typeof currentProps.y1 === 'number' ? currentProps.y1 : 0; + } else if (removed.includes('y1')) { + line.left.y = 0; } - } - }, [shapeProps, line, x1, y1, x2, y2]); - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(line); - }; - }, [line, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(line, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(line); - } - }, [ - line, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); + if ('x2' in changed) { + line.right.x = typeof currentProps.x2 === 'number' ? currentProps.x2 : 0; + } else if (removed.includes('x2')) { + line.right.x = 0; + } - useImperativeHandle(forwardedRef, () => line, [line]); + if ('y2' in changed) { + line.right.y = typeof currentProps.y2 === 'number' ? currentProps.y2 : 0; + } else if (removed.includes('y2')) { + line.right.y = 0; + } + }, + }); return <>; } diff --git a/lib/LinearGradient.tsx b/lib/LinearGradient.tsx index 7b3c7c6..2bd90f1 100644 --- a/lib/LinearGradient.tsx +++ b/lib/LinearGradient.tsx @@ -1,8 +1,8 @@ -import React, { useImperativeHandle, useEffect, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; - import type { LinearGradient as Instance } from 'two.js/src/effects/linear-gradient'; import { GradientProps } from './Properties'; +import { useTwoObject } from './useTwoObject'; type LinearGradientProps = GradientProps | 'left' | 'right'; @@ -20,34 +20,46 @@ type ComponentProps = React.PropsWithChildren< export type RefLinearGradient = Instance; export const LinearGradient = React.forwardRef( - ({ x1, y1, x2, y2, ...props }, forwardedRef) => { - const gradient = useMemo(() => new Two.LinearGradient(), []); - - useEffect(() => { - if (typeof x1 === 'number') { - gradient.left.x = x1; - } - if (typeof y1 === 'number') { - gradient.left.y = y1; - } - if (typeof x2 === 'number') { - gradient.right.x = x2; - } - if (typeof y2 === 'number') { - gradient.right.y = y2; - } - - // Update other properties (excluding event handlers) - for (const key in props) { - if (key in gradient) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (gradient as any)[key] = (props as any)[key]; + (props, forwardedRef) => { + useTwoObject(props as Record, forwardedRef, { + factory: () => new Two.LinearGradient(), + isSceneObject: false, + specialProps: ['x1', 'y1', 'x2', 'y2'], + applySpecialProps: (gradient, currentProps, changed, removed) => { + const p = currentProps as unknown as { + x1?: number; + y1?: number; + x2?: number; + y2?: number; + }; + const grad = gradient as unknown as Instance; + + if ('x1' in changed) { + grad.left.x = typeof p.x1 === 'number' ? p.x1 : 0; + } else if (removed.includes('x1')) { + grad.left.x = 0; + } + + if ('y1' in changed) { + grad.left.y = typeof p.y1 === 'number' ? p.y1 : 0; + } else if (removed.includes('y1')) { + grad.left.y = 0; } - } - }, [gradient, x1, y1, x2, y2, props]); - useImperativeHandle(forwardedRef, () => gradient, [gradient]); + if ('x2' in changed) { + grad.right.x = typeof p.x2 === 'number' ? p.x2 : 0; + } else if (removed.includes('x2')) { + grad.right.x = 0; + } + + if ('y2' in changed) { + grad.right.y = typeof p.y2 === 'number' ? p.y2 : 0; + } else if (removed.includes('y2')) { + grad.right.y = 0; + } + }, + }); - return null; // No visual representation + return null; } ); diff --git a/lib/Path.tsx b/lib/Path.tsx index b0b6ef4..dcc3da2 100644 --- a/lib/Path.tsx +++ b/lib/Path.tsx @@ -1,10 +1,8 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Path as Instance } from 'two.js/src/path'; import { ShapeProps, type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type PathProps = | ShapeProps @@ -23,6 +21,7 @@ export type PathProps = | 'ending' | 'dashes' | 'vertices'; + type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; @@ -36,86 +35,18 @@ type ComponentProps = React.PropsWithChildren< export type RefPath = Instance; export const Path = React.forwardRef( - ({ manual, x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const path = useMemo(() => new Two.Path(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Path(), + specialProps: ['manual'], + applySpecialProps: (path, currentProps, changed, removed) => { + if ('manual' in changed) { + path.automatic = !currentProps.manual; + } else if (removed.includes('manual')) { + path.automatic = true; } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(path); - - return () => { - parent.remove(path); - }; - } - }, [parent, path]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(path); - }; - }, [path, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(path, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(path); - } - }, [ - path, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useEffect(() => { - // Update position - if (typeof x === 'number') path.translation.x = x; - if (typeof y === 'number') path.translation.y = y; - - if (typeof manual !== 'undefined') { - path.automatic = !manual; - } - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in path) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (path as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, path, x, y, manual]); - - useImperativeHandle(forwardedRef, () => path, [path]); + }, + }); return <>; } diff --git a/lib/Points.tsx b/lib/Points.tsx index 6324b03..d5d6b9c 100644 --- a/lib/Points.tsx +++ b/lib/Points.tsx @@ -1,10 +1,8 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Points as Instance } from 'two.js/src/shapes/points'; import { ShapeProps, type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type PointsProps = | ShapeProps @@ -19,6 +17,7 @@ type PointsProps = | 'ending' | 'dashes' | 'vertices'; + type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; @@ -31,82 +30,10 @@ type ComponentProps = React.PropsWithChildren< export type RefPoints = Instance; export const Points = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const points = useMemo(() => new Two.Points(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(points); - - return () => { - parent.remove(points); - }; - } - }, [parent, points]); - - useEffect(() => { - // Update position - if (typeof x === 'number') points.translation.x = x; - if (typeof y === 'number') points.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in points) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (points as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, points, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(points); - }; - }, [points, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(points, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(points); - } - }, [ - points, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => points, [points]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Points(), + }); return <>; } diff --git a/lib/Polygon.tsx b/lib/Polygon.tsx index de4bd89..c97305c 100644 --- a/lib/Polygon.tsx +++ b/lib/Polygon.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Polygon as Instance } from 'two.js/src/shapes/polygon'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type PolygonProps = PathProps | 'width' | 'height' | 'sides'; type ComponentProps = React.PropsWithChildren< @@ -21,82 +19,11 @@ type ComponentProps = React.PropsWithChildren< export type RefPolygon = Instance; export const Polygon = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const polygon = useMemo(() => new Two.Polygon(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(polygon); - - return () => { - parent.remove(polygon); - }; - } - }, [parent, polygon]); - - useEffect(() => { - // Update position - if (typeof x === 'number') polygon.translation.x = x; - if (typeof y === 'number') polygon.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in polygon) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (polygon as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, polygon, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(polygon); - }; - }, [polygon, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(polygon, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(polygon); - } - }, [ - polygon, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => polygon, [polygon]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.Polygon(0, 0, p.radius, p.sides), + constructionProps: ['sides'], + }); return <>; } diff --git a/lib/Provider.tsx b/lib/Provider.tsx index 5e48cce..8787f47 100644 --- a/lib/Provider.tsx +++ b/lib/Provider.tsx @@ -11,11 +11,14 @@ import Two from 'two.js'; import type { Shape } from 'two.js/src/shape'; import type { Group } from 'two.js/src/group'; import { + ChildSlotContext, TwoCoreContext, TwoParentContext, TwoSizeContext, useTwo, + type TwoParentContextValue, } from './Context'; +import { reconcileSceneOrder } from './reconciliation'; import type { EventHandlers } from './Events'; import { clientToWorldPoint, @@ -134,12 +137,35 @@ export const Provider = React.forwardRef< const eventShapes = useRef>(new Map()); const hoveredShapes = useRef>(new Set()); const capturedShape = useRef(null); + const childrenOrderRef = useRef>([]); + const attachedChildrenRef = useRef>(new Set()); const [twoState, setTwoState] = useState(two); const [parentState, setParentState] = useState(parent); const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); + const attachChild = useCallback((child: Shape | Group) => { + attachedChildrenRef.current.add(child); + parentState?.add(child); + }, [parentState]); + + const detachChild = useCallback((child: Shape | Group) => { + attachedChildrenRef.current.delete(child); + parentState?.remove(child); + }, [parentState]); + + const registerChildOrder = useCallback((child: Shape | Group) => { + childrenOrderRef.current.push(child); + }, []); + + useLayoutEffect(() => { + if (parentState && childrenOrderRef.current.length > 0) { + reconcileSceneOrder(parentState, childrenOrderRef.current); + childrenOrderRef.current = []; + } + }); + // Separate Two.js constructor props from DOM element props const { twoProps, domProps } = useMemo(() => { const twoProps: Record = {}; @@ -226,6 +252,10 @@ export const Provider = React.forwardRef< setWidth(two.width); setHeight(two.height); + const shapes = eventShapes.current; + const hovered = hoveredShapes.current; + const attached = attachedChildrenRef.current; + return () => { two.renderer.domElement.parentElement?.removeChild( two.renderer.domElement, @@ -238,6 +268,11 @@ export const Provider = React.forwardRef< Two.Instances.splice(index, 1); } two.clear(); + shapes.clear(); + hovered.clear(); + capturedShape.current = null; + attached.clear(); + childrenOrderRef.current = []; }; } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -587,11 +622,14 @@ export const Provider = React.forwardRef< [twoState, registerEventShape, unregisterEventShape, hitTestPoint], ); - const parentValue = useMemo( + const parentValue = useMemo( () => ({ parent: parentState, + attachChild, + detachChild, + registerChildOrder, }), - [parentState], + [parentState, attachChild, detachChild, registerChildOrder], ); const sizeValue = useMemo( @@ -607,7 +645,14 @@ export const Provider = React.forwardRef<
- {props.children} + {React.Children.map(props.children, (child, index) => { + if (!React.isValidElement(child)) return child; + return ( + + {child} + + ); + })}
diff --git a/lib/RadialGradient.tsx b/lib/RadialGradient.tsx index c06d0b0..01178c7 100644 --- a/lib/RadialGradient.tsx +++ b/lib/RadialGradient.tsx @@ -1,8 +1,8 @@ -import React, { useImperativeHandle, useEffect, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; - import type { RadialGradient as Instance } from 'two.js/src/effects/radial-gradient'; import { GradientProps } from './Properties'; +import { useTwoObject } from './useTwoObject'; type RadialGradientProps = GradientProps | 'center' | 'radius' | 'focal'; @@ -20,27 +20,46 @@ type ComponentProps = React.PropsWithChildren< export type RefRadialGradient = Instance; export const RadialGradient = React.forwardRef( - ({ x, y, focalX, focalY, ...props }, forwardedRef) => { - const radialGradient = useMemo(() => new Two.RadialGradient(), []); + (props, forwardedRef) => { + useTwoObject(props as Record, forwardedRef, { + factory: () => new Two.RadialGradient(), + isSceneObject: false, + specialProps: ['x', 'y', 'focalX', 'focalY'], + applySpecialProps: (gradient, currentProps, changed, removed) => { + const p = currentProps as unknown as { + x?: number; + y?: number; + focalX?: number; + focalY?: number; + }; + const grad = gradient as unknown as Instance; - useEffect(() => { - if (typeof x === 'number') radialGradient.center.x = x; - if (typeof y === 'number') radialGradient.center.y = y; + if ('x' in changed) { + grad.center.x = typeof p.x === 'number' ? p.x : 0; + } else if (removed.includes('x')) { + grad.center.x = 0; + } - if (typeof focalX === 'number') radialGradient.focal.x = focalX; - if (typeof focalY === 'number') radialGradient.focal.y = focalY; + if ('y' in changed) { + grad.center.y = typeof p.y === 'number' ? p.y : 0; + } else if (removed.includes('y')) { + grad.center.y = 0; + } - // Update other properties (excluding event handlers) - for (const key in props) { - if (key in radialGradient) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (radialGradient as any)[key] = (props as any)[key]; + if ('focalX' in changed) { + grad.focal.x = typeof p.focalX === 'number' ? p.focalX : 0; + } else if (removed.includes('focalX')) { + grad.focal.x = 0; } - } - }, [props, radialGradient, x, y, focalX, focalY]); - useImperativeHandle(forwardedRef, () => radialGradient, [radialGradient]); + if ('focalY' in changed) { + grad.focal.y = typeof p.focalY === 'number' ? p.focalY : 0; + } else if (removed.includes('focalY')) { + grad.focal.y = 0; + } + }, + }); - return null; // No visual representation + return null; } ); diff --git a/lib/Rectangle.tsx b/lib/Rectangle.tsx index 77aadae..a6f97fb 100644 --- a/lib/Rectangle.tsx +++ b/lib/Rectangle.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Rectangle as Instance } from 'two.js/src/shapes/rectangle'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; export type RectangleProps = PathProps | 'width' | 'height'; type ComponentProps = React.PropsWithChildren< @@ -20,82 +18,10 @@ type ComponentProps = React.PropsWithChildren< export type RefRectangle = Instance; export const Rectangle = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const rectangle = useMemo(() => new Two.Rectangle(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(rectangle); - - return () => { - parent.remove(rectangle); - }; - } - }, [parent, rectangle]); - - useEffect(() => { - // Update position - if (typeof x === 'number') rectangle.translation.x = x; - if (typeof y === 'number') rectangle.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in rectangle) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (rectangle as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, rectangle, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(rectangle); - }; - }, [rectangle, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(rectangle, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(rectangle); - } - }, [ - rectangle, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => rectangle, [rectangle]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Rectangle(), + }); return <>; } diff --git a/lib/RoundedRectangle.tsx b/lib/RoundedRectangle.tsx index 0a3c1cd..62e9c2e 100644 --- a/lib/RoundedRectangle.tsx +++ b/lib/RoundedRectangle.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { RoundedRectangle as Instance } from 'two.js/src/shapes/rounded-rectangle'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type RoundedRectangleProps = PathProps | 'width' | 'height' | 'radius'; type ComponentProps = React.PropsWithChildren< @@ -20,88 +18,10 @@ type ComponentProps = React.PropsWithChildren< export type RefRoundedRectangle = Instance; export const RoundedRectangle = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const roundedRectangle = useMemo(() => new Two.RoundedRectangle(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(roundedRectangle); - - return () => { - parent.remove(roundedRectangle); - }; - } - }, [parent, roundedRectangle]); - - useEffect(() => { - // Update position - if (typeof x === 'number') roundedRectangle.translation.x = x; - if (typeof y === 'number') roundedRectangle.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in roundedRectangle) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (roundedRectangle as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, roundedRectangle, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(roundedRectangle); - }; - }, [roundedRectangle, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape( - roundedRectangle, - eventHandlers, - parent ?? undefined - ); - } else { - unregisterEventShape(roundedRectangle); - } - }, [ - roundedRectangle, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => roundedRectangle, [ - roundedRectangle, - ]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.RoundedRectangle(), + }); return <>; } diff --git a/lib/SVG.tsx b/lib/SVG.tsx index 1f9e8ea..85ab5c8 100644 --- a/lib/SVG.tsx +++ b/lib/SVG.tsx @@ -1,12 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo, useRef } from 'react'; -import Two from 'two.js'; +import React, { useEffect, useRef } from 'react'; import { Context, TwoParentContext, TwoSizeContext, useTwo } from './Context'; - import type { Group as Instance } from 'two.js/src/group'; import { ShapeProps, type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoGroup } from './useTwoObject'; -// SVG-specific props type GroupProps = | ShapeProps | 'fill' @@ -24,42 +21,34 @@ type ComponentProps = React.PropsWithChildren< [K in Extract]?: Instance[K]; } & ( | { - // Source (one required) - src: string; // URL to .svg file - content?: never; // Inline SVG markup string + src: string; + content?: never; } | { - src?: never; // URL to .svg file - content: string; // Inline SVG markup string + src?: never; + content: string; } ) & { x?: number; y?: number; onLoad?: (group: Instance, svg: SVGElement | SVGElement[]) => void; onError?: (error: Error) => void; - shallow?: boolean; // Flatten groups when interpreting + shallow?: boolean; } & Partial >; export type RefSVG = Instance; export const SVG = React.forwardRef( - ({ x, y, src, content, onLoad, onError, ...props }, forwardedRef) => { - const { - two, - parent, - width, - height, - registerEventShape, - unregisterEventShape, - hitTestPoint, - } = useTwo(); - const svg = useMemo(() => new Two.Group(), []); - const ref = useRef(null); + (props, forwardedRef) => { + const { two } = useTwo(); + const { src, content, onLoad, onError, shallow, ...restProps } = props; + const onLoadRef = useRef(onLoad); const onErrorRef = useRef(onError); + const rafRef = useRef(null); const lastLoadedSource = useRef<{ - two: Two | null; + two: unknown; key: string | null; }>({ two: null, key: null }); @@ -71,44 +60,13 @@ export const SVG = React.forwardRef( onErrorRef.current = onError; }, [onError]); - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - // Hoist instance for async access - useEffect(() => { - ref.current = svg; - }, [svg]); - - // Add group to parent - useEffect(() => { - if (parent && svg) { - parent.add(svg); - - return () => { - parent.remove(svg); - }; - } - }, [svg, parent]); + const { + instance: svg, + coreValue, + parentValue, + sizeValue, + renderChildren, + } = useTwoGroup(restProps, forwardedRef); // Validate props useEffect(() => { @@ -134,7 +92,6 @@ export const SVG = React.forwardRef( const currentKey = source; const last = lastLoadedSource.current; - // Skip reload if the source and Two instance are unchanged if (last.two === two && last.key === currentKey) { return; } @@ -143,26 +100,31 @@ export const SVG = React.forwardRef( lastLoadedSource.current = { two, key: currentKey }; try { - // two.load() returns a Group immediately (empty initially) - // and populates it asynchronously via callback two.load( source, - (loadedGroup: Instance, svg: SVGElement | SVGElement[]) => { + (loadedGroup: Instance, svgElement: SVGElement | SVGElement[]) => { if (!mounted) return; - ref.current?.add(loadedGroup.children); - // Invoke user callback if provided + if (shallow) { + svg.add(loadedGroup.children); + } else { + svg.add(loadedGroup.children); + } + const handleLoad = onLoadRef.current; if (handleLoad) { try { - // Wait until next frame once Two.js has computed - // all necessary rendering / bounding box updates - requestAnimationFrame(() => handleLoad(ref.current!, svg)); + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + if (mounted) { + handleLoad(svg, svgElement); + } + }); } catch (err) { - console.error( - '[react-two.js] Error in SVG onLoad callback:', - err - ); + console.error('[react-two.js] Error in SVG onLoad callback:', err); } } } @@ -189,88 +151,21 @@ export const SVG = React.forwardRef( } return () => { - // Note: Two.js XHR requests cannot be cancelled - // We track mounted state to prevent setState on unmounted component mounted = false; - // Reset last loaded key so the same source can be reloaded after cleanup - lastLoadedSource.current = { two: null, key: null }; - // Remove previously added children - ref.current?.remove(ref.current.children); - }; - }, [two, src, content]); - - // Update position and properties - useEffect(() => { - // Update position - if (typeof x === 'number') svg.translation.x = x; - if (typeof y === 'number') svg.translation.y = y; - - const args = { ...shapeProps }; - delete args.children; // Allow react to handle children - - // Update other properties (excluding event handlers) - for (const key in args) { - if (key in svg) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (svg as any)[key] = (args as any)[key]; + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; } - } - }, [svg, x, y, shapeProps]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(svg); + lastLoadedSource.current = { two: null, key: null }; + svg.remove(svg.children); }; - }, [svg, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(svg, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(svg); - } - }, [ - svg, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => svg, [svg]); - - const coreValue = useMemo( - () => ({ - two, - registerEventShape, - unregisterEventShape, - hitTestPoint, - }), - [two, registerEventShape, unregisterEventShape, hitTestPoint] - ); - - const parentValue = useMemo( - () => ({ - parent: svg, - }), - [svg] - ); - - const sizeValue = useMemo( - () => ({ - width, - height, - }), - [width, height] - ); + }, [two, src, content, shallow, svg]); return ( - {props.children} + {renderChildren()} diff --git a/lib/Sprite.tsx b/lib/Sprite.tsx index d55eeb0..fba2825 100644 --- a/lib/Sprite.tsx +++ b/lib/Sprite.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Sprite as Instance } from 'two.js/src/effects/sprite'; import { RectangleProps } from './Rectangle'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type SpriteProps = | RectangleProps @@ -34,88 +32,22 @@ type ComponentProps = React.PropsWithChildren< export type RefSprite = Instance; export const Sprite = React.forwardRef( - ({ src, x, y, autoPlay, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const sprite = useMemo(() => new Two.Sprite(src), [src]); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: (p) => new Two.Sprite(p.src), + constructionProps: ['src'], + specialProps: ['autoPlay'], + applySpecialProps: (sprite, currentProps) => { + if (currentProps.autoPlay) { + sprite.play(); } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; + sprite.pause(); } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(sprite); - - return () => { - parent.remove(sprite); - }; - } - }, [parent, sprite]); - - useEffect(() => { - // Update position - if (typeof x === 'number') sprite.translation.x = x; - if (typeof y === 'number') sprite.translation.y = y; - - if (autoPlay) { - sprite.play(); - } else { + }, + disposeOwned: (sprite) => { sprite.pause(); - } - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in sprite) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (sprite as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, sprite, x, y, autoPlay]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(sprite); - }; - }, [sprite, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(sprite, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(sprite); - } - }, [ - sprite, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => sprite, [sprite]); + }, + }); return <>; } diff --git a/lib/Star.tsx b/lib/Star.tsx index f3d0e62..9437b9b 100644 --- a/lib/Star.tsx +++ b/lib/Star.tsx @@ -1,11 +1,9 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Star as Instance } from 'two.js/src/shapes/star'; import { PathProps } from './Path'; import { type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type StarProps = PathProps | 'innerRadius' | 'outerRadius' | 'sides'; type ComponentProps = React.PropsWithChildren< @@ -20,82 +18,10 @@ type ComponentProps = React.PropsWithChildren< export type RefStar = Instance; export const Star = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const star = useMemo(() => new Two.Star(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(star); - - return () => { - parent.remove(star); - }; - } - }, [parent, star]); - - useEffect(() => { - // Update position - if (typeof x === 'number') star.translation.x = x; - if (typeof y === 'number') star.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in star) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (star as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, star, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(star); - }; - }, [star, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(star, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(star); - } - }, [ - star, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => star, [star]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Star(), + }); return <>; } diff --git a/lib/Text.tsx b/lib/Text.tsx index af76d22..96eef49 100644 --- a/lib/Text.tsx +++ b/lib/Text.tsx @@ -1,10 +1,8 @@ -import React, { useEffect, useImperativeHandle, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; -import { useTwo } from './Context'; - import type { Text as Instance } from 'two.js/src/text'; import { ShapeProps, type EventHandlers } from './Properties'; -import { EVENT_HANDLER_NAMES } from './Events'; +import { useTwoObject } from './useTwoObject'; type TextProps = | ShapeProps @@ -24,6 +22,7 @@ type TextProps = | 'fill' | 'stroke' | 'dashes'; + type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; @@ -36,82 +35,10 @@ type ComponentProps = React.PropsWithChildren< export type RefText = Instance; export const Text = React.forwardRef( - ({ x, y, ...props }, forwardedRef) => { - const { parent, registerEventShape, unregisterEventShape } = useTwo(); - - // Create the instance synchronously so it's available for refs immediately - const text = useMemo(() => new Two.Text(), []); - - // Extract event handlers from props - const { eventHandlers, shapeProps } = useMemo(() => { - const eventHandlers: Partial = {}; - const shapeProps: Record = {}; - - for (const key in props) { - if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { - // An explicitly `undefined` handler means "not interactive", so it - // must not count toward the registered handler set. - const handler = props[key as keyof EventHandlers]; - if (handler !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - eventHandlers[key as keyof EventHandlers] = handler as any; - } - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shapeProps[key] = (props as any)[key]; - } - } - - return { eventHandlers, shapeProps }; - }, [props]); - - useEffect(() => { - if (parent) { - parent.add(text); - - return () => { - parent.remove(text); - }; - } - }, [parent, text]); - - useEffect(() => { - // Update position - if (typeof x === 'number') text.translation.x = x; - if (typeof y === 'number') text.translation.y = y; - - // Update other properties (excluding event handlers) - for (const key in shapeProps) { - if (key in text) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (text as any)[key] = (shapeProps as any)[key]; - } - } - }, [shapeProps, text, x, y]); - - // Unregister on unmount only - useEffect(() => { - return () => { - unregisterEventShape(text); - }; - }, [text, unregisterEventShape]); - - // Register / update event handlers - useEffect(() => { - if (Object.keys(eventHandlers).length > 0) { - registerEventShape(text, eventHandlers, parent ?? undefined); - } else { - unregisterEventShape(text); - } - }, [ - text, - registerEventShape, - unregisterEventShape, - parent, - eventHandlers, - ]); - - useImperativeHandle(forwardedRef, () => text, [text]); + (props, forwardedRef) => { + useTwoObject(props, forwardedRef, { + factory: () => new Two.Text(), + }); return <>; } diff --git a/lib/Texture.tsx b/lib/Texture.tsx index b120037..9d0ae58 100644 --- a/lib/Texture.tsx +++ b/lib/Texture.tsx @@ -1,8 +1,8 @@ -import React, { useImperativeHandle, useEffect, useMemo } from 'react'; +import React from 'react'; import Two from 'two.js'; - import type { Texture as Instance } from 'two.js/src/effects/texture'; import { ElementProps } from './Properties'; +import { useTwoObject } from './useTwoObject'; export type TextureProps = | ElementProps @@ -12,6 +12,7 @@ export type TextureProps = | 'scale' | 'offset' | 'image'; + type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; @@ -23,22 +24,13 @@ type ComponentProps = React.PropsWithChildren< export type RefTexture = Instance; export const Texture = React.forwardRef( - ({ src, ...props }, forwardedRef) => { - // Create the instance synchronously so it's available for refs immediately - const texture = useMemo(() => new Two.Texture(src), [src]); - - useEffect(() => { - // Update other properties (excluding event handlers) - for (const key in props) { - if (key in texture) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (texture as any)[key] = (props as any)[key]; - } - } - }, [props, texture]); - - useImperativeHandle(forwardedRef, () => texture, [texture]); + (props, forwardedRef) => { + useTwoObject(props as Record, forwardedRef, { + factory: (p) => new Two.Texture(p.src as string | HTMLImageElement), + constructionProps: ['src'], + isSceneObject: false, + }); - return null; // No visual representation + return null; } ); diff --git a/lib/main.ts b/lib/main.ts index 3505b7b..8e79cf2 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -34,6 +34,10 @@ export { RadialGradient, type RefRadialGradient } from './RadialGradient'; // Texture exports export { Texture, type RefTexture } from './Texture'; +// Lifecycle & Reconciliation exports +export { useTwoObject, useTwoGroup } from './useTwoObject'; +export { TWO_DEFAULT_PROPS } from './reconciliation'; + // Event exports export type { TwoEvent, EventHandler, EventHandlers } from './Events'; diff --git a/lib/reconciliation.ts b/lib/reconciliation.ts new file mode 100644 index 0000000..d1e4785 --- /dev/null +++ b/lib/reconciliation.ts @@ -0,0 +1,194 @@ +import type { Shape } from 'two.js/src/shape'; +import type { Group } from 'two.js/src/group'; + +/** + * Standard default values for Two.js properties. + * Used when a previously specified React prop is removed or set to undefined. + */ +export const TWO_DEFAULT_PROPS: Record = { + // Shape / Path styling + fill: '#fff', + stroke: '#000', + linewidth: 1, + opacity: 1, + visible: true, + cap: 'round', + join: 'round', + miter: 4, + closed: true, + curved: false, + automatic: true, + beginning: 0, + ending: 1, + dashes: [], + + // Positioning & transforms + x: 0, + y: 0, + rotation: 0, + scale: 1, + + // Text defaults + value: '', + family: 'sans-serif', + size: 13, + leading: 17, + alignment: 'middle', + baseline: 'middle', + style: 'normal', + weight: 'normal', + decoration: 'none', + direction: 'ltr', + + // Line endpoints + x1: 0, + y1: 0, + x2: 0, + y2: 0, + + // Circle / Rectangle dimensions + radius: 0, + width: 0, + height: 0, + + // Special flags + manual: false, +}; + +/** + * Capture default property values present on a newly instantiated Two.js object. + */ +export function captureDefaultProps( + instance: Record +): Record { + const defaults: Record = { ...TWO_DEFAULT_PROPS }; + + for (const key of Object.keys(TWO_DEFAULT_PROPS)) { + if (key in instance && instance[key] !== undefined) { + defaults[key] = instance[key]; + } + } + + // Also capture instance-specific properties if defined + if ('translation' in instance && instance.translation) { + const translation = instance.translation as { x?: number; y?: number }; + defaults.x = translation.x ?? 0; + defaults.y = translation.y ?? 0; + } + + return defaults; +} + +/** + * Result of diffing incoming props against previously applied props. + */ +export interface PropDiff

{ + changed: Partial

; + removed: Array; + hasChanges: boolean; +} + +/** + * Diffs incoming props against previously applied props. + * - Properties with new/changed values are in `changed`. + * - Properties present in `prevProps` but missing/undefined in `nextProps` are in `removed`. + * - Unchanged properties are omitted. + */ +export function diffProps

>( + prevProps: P, + nextProps: P, + ignoredKeys: Set = new Set() +): PropDiff

{ + const changed: Partial

= {}; + const removed: Array = []; + + // Check for changed or newly added props + for (const key in nextProps) { + if (ignoredKeys.has(key)) continue; + + const nextVal = nextProps[key]; + const prevVal = prevProps[key]; + + if (nextVal !== prevVal) { + if (nextVal === undefined) { + // Setting to undefined is treated as prop removal + if (key in prevProps && prevVal !== undefined) { + removed.push(key as keyof P); + } + } else { + changed[key as keyof P] = nextVal; + } + } + } + + // Check for removed props + for (const key in prevProps) { + if (ignoredKeys.has(key)) continue; + + if (!(key in nextProps) && prevProps[key] !== undefined) { + if (!removed.includes(key as keyof P)) { + removed.push(key as keyof P); + } + } + } + + const hasChanges = + Object.keys(changed).length > 0 || removed.length > 0; + + return { changed, removed, hasChanges }; +} + +/** + * Helper to safely reorder children inside a Two.js parent Group + * to match a given target order array. + */ +export function reconcileSceneOrder( + parent: Group, + targetOrder: Array +): void { + if (!parent || !parent.children || targetOrder.length === 0) return; + + const children = parent.children as unknown as Array; + if (children.length <= 1) return; + + // Build target position map + const targetMap = new Map(); + targetOrder.forEach((item, idx) => { + targetMap.set(item, idx); + }); + + let needsReorder = false; + let lastSeenIndex = -1; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const targetIdx = targetMap.get(child); + if (targetIdx !== undefined) { + if (targetIdx < lastSeenIndex) { + needsReorder = true; + break; + } + lastSeenIndex = targetIdx; + } + } + + if (!needsReorder) return; + + // Stable sort children array according to targetMap + children.sort((a, b) => { + const indexA = targetMap.get(a); + const indexB = targetMap.get(b); + + if (indexA !== undefined && indexB !== undefined) { + return indexA - indexB; + } + if (indexA !== undefined) return -1; + if (indexB !== undefined) return 1; + return 0; + }); + + // Flag Two.js order update so renderer knows the scenegraph order changed + if (typeof (parent as unknown as { _flagOrder?: boolean })._flagOrder !== 'undefined') { + (parent as unknown as { _flagOrder: boolean })._flagOrder = true; + } +} diff --git a/lib/useTwoObject.ts b/lib/useTwoObject.ts new file mode 100644 index 0000000..92f997c --- /dev/null +++ b/lib/useTwoObject.ts @@ -0,0 +1,457 @@ +import React, { + useCallback, + useContext, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, +} from 'react'; +import Two from 'two.js'; +import type { Shape } from 'two.js/src/shape'; +import type { Group } from 'two.js/src/group'; +import { + ChildSlotContext, + useTwo, + type TwoCoreContextValue, + type TwoParentContextValue, + type TwoSizeContextValue, +} from './Context'; +import { EVENT_HANDLER_NAMES, type EventHandlers } from './Events'; +import { + captureDefaultProps, + diffProps, + reconcileSceneOrder, + TWO_DEFAULT_PROPS, +} from './reconciliation'; + +export type TwoSceneItem = Shape | Group; + +const IGNORED_PROP_KEYS = new Set([ + 'children', + 'key', + 'ref', + ...EVENT_HANDLER_NAMES, +]); + +export interface TwoObjectConfig< + T, + P extends Record +> { + /** + * Factory function to instantiate the Two.js object. + */ + factory: (props: P) => T; + + /** + * Prop keys that require destroying and recreating the object if changed + * (e.g. ['resolution'], ['sides'], ['spokes'], ['src']). + */ + constructionProps?: Array; + + /** + * Prop keys that should not be assigned directly to instance[key] + * (e.g. ['x', 'y', 'x1', 'y1', 'manual', 'autoPlay']). + */ + specialProps?: Array; + + /** + * Custom handler to apply changed/removed special props to the instance. + */ + applySpecialProps?: ( + instance: T, + props: P, + changed: Partial

, + removed: Array + ) => void; + + /** + * Custom disposal logic for owned resources (e.g. internal textures). + */ + disposeOwned?: (instance: T) => void; + + /** + * Whether this object is added to parent.children. Defaults to true. + * False for non-scene objects like LinearGradient, RadialGradient, Texture. + */ + isSceneObject?: boolean; +} + +/** + * Standard handler for applying position (x, y) to translation. + */ +export function applyDefaultPositionProps( + instance: T, + props: { x?: number; y?: number }, + changed: { x?: number; y?: number }, + removed: Array +): void { + const inst = instance as unknown as Record; + if ('translation' in inst && inst.translation) { + const translation = inst.translation as { x: number; y: number }; + + if ('x' in changed) { + translation.x = typeof props.x === 'number' ? props.x : 0; + } else if (removed.includes('x')) { + translation.x = 0; + } + + if ('y' in changed) { + translation.y = typeof props.y === 'number' ? props.y : 0; + } else if (removed.includes('y')) { + translation.y = 0; + } + } +} + +/** + * Shared lifecycle hook for Two.js scene objects and effects. + */ +export function useTwoObject< + T, + P extends Record +>( + props: P, + forwardedRef: React.ForwardedRef, + config: TwoObjectConfig +): { + instance: T; + eventHandlers: Partial; + shapeProps: Record; +} { + const { + parent, + attachChild, + detachChild, + registerChildOrder, + registerEventShape, + unregisterEventShape, + } = useTwo(); + + // Listen to ChildSlotContext to guarantee re-render when sibling order changes, + // even if this component was wrapped in React.memo + useContext(ChildSlotContext); + + const isSceneObject = config.isSceneObject !== false; + const constructionProps = config.constructionProps ?? []; + const specialPropsSet = useMemo( + () => new Set(['x', 'y', ...(config.specialProps as string[] ?? [])]), + [config.specialProps] + ); + + // Extract event handlers vs shape props + const { eventHandlers, shapeProps } = useMemo(() => { + const handlers: Partial = {}; + const shape: Record = {}; + + for (const key in props) { + if (EVENT_HANDLER_NAMES.includes(key as keyof EventHandlers)) { + const handler = props[key as keyof EventHandlers]; + if (handler !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handlers[key as keyof EventHandlers] = handler as any; + } + } else { + shape[key] = props[key]; + } + } + + return { eventHandlers: handlers, shapeProps: shape }; + }, [props]); + + // Track construction props to detect when recreation is necessary + const lastConstructionPropsRef = useRef>({}); + const instanceRef = useRef(null); + const defaultPropsRef = useRef>({}); + const prevPropsRef = useRef>({}); + const isInitialMountRef = useRef(true); + + // Check if any construction-only prop changed + let needsRecreation = instanceRef.current === null; + if (!needsRecreation) { + for (const cp of constructionProps) { + if (props[cp] !== lastConstructionPropsRef.current[cp as string]) { + needsRecreation = true; + break; + } + } + } + + // Create or recreate instance synchronously so it's ready during render / ref forwarding + if (needsRecreation) { + const oldInstance = instanceRef.current; + const newInstance = config.factory(props); + + if (oldInstance) { + // In-place replacement in parent.children if attached + if (parent && isSceneObject) { + const children = parent.children as unknown as Array; + const idx = children.indexOf(oldInstance); + if (idx !== -1) { + children.splice(idx, 1, newInstance); + (newInstance as unknown as { parent?: Group }).parent = parent; + if (typeof (parent as unknown as { _flagOrder?: boolean })._flagOrder !== 'undefined') { + (parent as unknown as { _flagOrder: boolean })._flagOrder = true; + } + } + } + + // Cleanup old owned resources and unregister old event handlers + unregisterEventShape(oldInstance as unknown as Shape | Group); + config.disposeOwned?.(oldInstance); + } + + instanceRef.current = newInstance; + defaultPropsRef.current = captureDefaultProps( + newInstance as unknown as Record + ); + + // Save construction props snapshot + const cSnapshot: Record = {}; + for (const cp of constructionProps) { + cSnapshot[cp as string] = props[cp]; + } + lastConstructionPropsRef.current = cSnapshot; + + // Reset prevProps so all properties are applied to the new instance + prevPropsRef.current = {}; + } + + const instance = instanceRef.current!; + + // Forward ref + useImperativeHandle(forwardedRef, () => instance, [instance]); + + // Apply discrete property updates and resets + useLayoutEffect(() => { + const prev = prevPropsRef.current; + const current = shapeProps; + + const { changed, removed, hasChanges } = diffProps( + prev, + current, + IGNORED_PROP_KEYS + ); + + if (hasChanges || isInitialMountRef.current) { + const instRecord = instance as unknown as Record; + + // 1. Apply changed properties + for (const key in changed) { + if (!specialPropsSet.has(key)) { + if (key in instRecord) { + instRecord[key] = changed[key]; + } + } + } + + // 2. Reset removed properties to Two.js defaults + for (const key of removed) { + if (!specialPropsSet.has(key as string)) { + if (key in instRecord) { + const defaultVal = + defaultPropsRef.current[key as string] ?? + TWO_DEFAULT_PROPS[key as string]; + if (defaultVal !== undefined) { + instRecord[key as string] = defaultVal; + } + } + } + } + + // 3. Handle default position props (x, y) + applyDefaultPositionProps( + instance, + props as { x?: number; y?: number }, + changed as { x?: number; y?: number }, + removed as string[] + ); + + // 4. Custom special props callback + if (config.applySpecialProps) { + config.applySpecialProps( + instance, + props, + changed as Partial

, + removed as Array + ); + } + + prevPropsRef.current = { ...current }; + isInitialMountRef.current = false; + } + }); + + // Scene-graph attachment and reparenting lifecycle + useLayoutEffect(() => { + if (!isSceneObject || !parent) return; + + const sceneItem = instance as unknown as Shape | Group; + + if (attachChild) { + attachChild(sceneItem); + } else { + parent.add(sceneItem as unknown as Shape); + } + + return () => { + if (detachChild) { + detachChild(sceneItem); + } else { + parent.remove(sceneItem as unknown as Shape); + } + }; + }, [instance, parent, isSceneObject, attachChild, detachChild]); + + // Sibling order registration (runs on every commit in document order) + useLayoutEffect(() => { + if (!isSceneObject || !parent) return; + + if (registerChildOrder) { + registerChildOrder(instance as unknown as Shape | Group); + } + }); + + const configRef = useRef(config); + configRef.current = config; + + // Event handler registration and cleanup + useEffect(() => { + const eventItem = instance as unknown as Shape | Group; + if (Object.keys(eventHandlers).length > 0) { + registerEventShape(eventItem, eventHandlers, parent ?? undefined); + } else { + unregisterEventShape(eventItem); + } + + return () => { + unregisterEventShape(eventItem); + }; + }, [instance, parent, eventHandlers, registerEventShape, unregisterEventShape]); + + // Cleanup on unmount (strict mode safe) + useEffect(() => { + return () => { + configRef.current.disposeOwned?.(instance); + }; + }, [instance]); + + return { instance, eventHandlers, shapeProps }; +} + +export interface UseTwoGroupResult { + instance: Group; + coreValue: TwoCoreContextValue; + parentValue: TwoParentContextValue; + sizeValue: TwoSizeContextValue; + renderChildren: () => React.ReactNode; +} + +/** + * Shared hook for Group components to coordinate child ordering and context propagation. + */ +export function useTwoGroup

>( + props: React.PropsWithChildren

, + forwardedRef: React.ForwardedRef, + config?: Partial> +): UseTwoGroupResult { + const { two, width, height, registerEventShape, unregisterEventShape, hitTestPoint } = + useTwo(); + + const childrenOrderRef = useRef>([]); + const attachedChildrenRef = useRef>(new Set()); + + // Attach a child to this group + const attachChild = useCallback((child: Shape | Group) => { + attachedChildrenRef.current.add(child); + groupRef.current?.add(child); + }, []); + + // Detach a child from this group + const detachChild = useCallback((child: Shape | Group) => { + attachedChildrenRef.current.delete(child); + groupRef.current?.remove(child); + }, []); + + // Register child order during commit + const registerChildOrder = useCallback((child: Shape | Group) => { + childrenOrderRef.current.push(child); + }, []); + + const groupRef = useRef(null); + + const { instance } = useTwoObject( + props as P, + forwardedRef, + { + factory: () => new Two.Group(), + ...config, + } + ); + + groupRef.current = instance; + + // Reconcile children order after all child layout effects run + useLayoutEffect(() => { + if (childrenOrderRef.current.length > 0) { + reconcileSceneOrder(instance, childrenOrderRef.current); + childrenOrderRef.current = []; + } + }); + + // Clean up all attached children on unmount + useEffect(() => { + const attached = attachedChildrenRef.current; + return () => { + attached.clear(); + childrenOrderRef.current = []; + }; + }, []); + + const coreValue = useMemo( + () => ({ + two, + registerEventShape, + unregisterEventShape, + hitTestPoint, + }), + [two, registerEventShape, unregisterEventShape, hitTestPoint] + ); + + const parentValue = useMemo( + () => ({ + parent: instance, + attachChild, + detachChild, + registerChildOrder, + }), + [instance, attachChild, detachChild, registerChildOrder] + ); + + const sizeValue = useMemo( + () => ({ + width, + height, + }), + [width, height] + ); + + const renderChildren = useCallback(() => { + return React.Children.map(props.children, (child, index) => { + if (!React.isValidElement(child)) return child; + return React.createElement( + ChildSlotContext.Provider, + { value: index, key: child.key ?? index }, + child + ); + }); + }, [props.children]); + + return { + instance, + coreValue, + parentValue, + sizeValue, + renderChildren, + }; +} diff --git a/tests/reconciliation.test.tsx b/tests/reconciliation.test.tsx new file mode 100644 index 0000000..6fdc181 --- /dev/null +++ b/tests/reconciliation.test.tsx @@ -0,0 +1,510 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render } from '@testing-library/react'; +import Two from 'two.js'; +import { + Canvas, + Group, + Circle, + Rectangle, + Polygon, + Text, + useTwo, + type RefCircle, + type RefGroup, + type RefRectangle, + type RefPolygon, +} from '../lib/main'; +import { TwoParentContext } from '../lib/Context'; +import { TWO_DEFAULT_PROPS } from '../lib/reconciliation'; + +beforeEach(() => { + HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({ + fillRect: vi.fn(), + clearRect: vi.fn(), + getImageData: vi.fn().mockReturnValue({ data: [] }), + putImageData: vi.fn(), + createImageData: vi.fn().mockReturnValue([]), + setTransform: vi.fn(), + drawImage: vi.fn(), + save: vi.fn(), + fillText: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + closePath: vi.fn(), + stroke: vi.fn(), + translate: vi.fn(), + scale: vi.fn(), + rotate: vi.fn(), + arc: vi.fn(), + fill: vi.fn(), + measureText: vi.fn().mockReturnValue({ width: 0 }), + transform: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + }); +}); + +describe('Two.js Scene Graph Reconciliation (Issue #29)', () => { + describe('1. Initial Mount & Order', () => { + it('mounts children into parent.children in JSX document order', () => { + const groupRef = React.createRef(); + const circleRef = React.createRef(); + const rectRef = React.createRef(); + const polyRef = React.createRef(); + + render( + + + + + + + + ); + + const group = groupRef.current!; + expect(group).not.toBeNull(); + expect(group.children.length).toBe(3); + expect(group.children[0]).toBe(circleRef.current); + expect(group.children[1]).toBe(rectRef.current); + expect(group.children[2]).toBe(polyRef.current); + }); + }); + + describe('2. Sibling Reordering', () => { + it('reorders parent.children when keyed JSX siblings reorder', () => { + const groupRef = React.createRef(); + const refs: Record = {}; + + function Item({ id }: { id: string }) { + return ( + { + if (el) refs[id] = el; + }} + radius={10} + /> + ); + } + + function App({ order }: { order: string[] }) { + return ( + + + {order.map((id) => ( + + ))} + + + ); + } + + const { rerender } = render(); + const group = groupRef.current!; + + expect(group.children.map((c: { id: string }) => c.id)).toEqual([ + refs['a'].id, + refs['b'].id, + refs['c'].id, + ]); + + // Reorder to ['c', 'a', 'b'] + rerender(); + + expect(group.children.map((c: { id: string }) => c.id)).toEqual([ + refs['c'].id, + refs['a'].id, + refs['b'].id, + ]); + + // Reorder to ['b', 'c', 'a'] + rerender(); + + expect(group.children.map((c: { id: string }) => c.id)).toEqual([ + refs['b'].id, + refs['c'].id, + refs['a'].id, + ]); + }); + + it('reorders parent.children even when sibling components are wrapped in React.memo', () => { + const groupRef = React.createRef(); + const refs: Record = {}; + + const MemoItem = React.memo(({ id }: { id: string }) => { + return ( + { + if (el) refs[id] = el; + }} + radius={15} + /> + ); + }); + + function App({ order }: { order: string[] }) { + return ( + + + {order.map((id) => ( + + ))} + + + ); + } + + const { rerender } = render(); + const group = groupRef.current!; + + expect(group.children.map((c: { id: string }) => c.id)).toEqual([ + refs['x'].id, + refs['y'].id, + refs['z'].id, + ]); + + // Reorder memoized siblings + rerender(); + + expect(group.children.map((c: { id: string }) => c.id)).toEqual([ + refs['z'].id, + refs['x'].id, + refs['y'].id, + ]); + }); + }); + + describe('3. Reparenting', () => { + it('moves a keyed object between groups, removing from old parent and inserting into new parent', () => { + const g1Ref = React.createRef(); + const g2Ref = React.createRef(); + const circleRef = React.createRef(); + + function App({ targetGroup }: { targetGroup: 'g1' | 'g2' }) { + return ( + + + + {targetGroup === 'g1' && ( + + )} + + + + {targetGroup === 'g2' && ( + + )} + + + + ); + } + + const { rerender } = render(); + const g1 = g1Ref.current!; + const g2 = g2Ref.current!; + const circle1 = circleRef.current!; + + expect(g1.children).toContain(circle1); + expect(g2.children).not.toContain(circle1); + expect(circle1.parent).toBe(g1); + + // Move circle from g1 to g2 + rerender(); + const circle2 = circleRef.current!; + + expect(g1.children).not.toContain(circle1); + expect(g2.children).toContain(circle2); + expect(circle2.parent).toBe(g2); + // It should be at index 1 between the two rectangles + expect(g2.children[1]).toBe(circle2); + }); + + it('reparents the same component instance when parent context changes dynamically', () => { + const circleRef = React.createRef(); + const g1 = new Two.Group(); + const g2 = new Two.Group(); + + function App({ parentGroup }: { parentGroup: Two.Group }) { + return ( + + + + + + ); + } + + const { rerender } = render(); + const circle = circleRef.current!; + + expect(g1.children).toContain(circle); + expect(g2.children).not.toContain(circle); + expect(circle.parent).toBe(g1); + + // Change parent to g2 without unmounting Circle + rerender(); + + expect(circleRef.current).toBe(circle); // Same instance! + expect(g1.children).not.toContain(circle); + expect(g2.children).toContain(circle); + expect(circle.parent).toBe(g2); + }); + }); + + describe('4. Discrete Prop Updates', () => { + it('updates only changed properties without reassigning unchanged properties', () => { + const circleRef = React.createRef(); + + function App({ x, fill }: { x: number; fill: string }) { + return ( + + + + ); + } + + const { rerender } = render(); + const circle = circleRef.current!; + + expect(circle.translation.x).toBe(100); + expect(circle.translation.y).toBe(50); + expect(circle.fill).toBe('red'); + expect(circle.stroke).toBe('#333'); + + // Clear Two.js internal dirty flags + circle._flagStroke = false; + circle._flagFill = false; + + // Update only x (position) + rerender(); + + expect(circle.translation.x).toBe(150); + expect(circle.fill).toBe('red'); + // Neither fill nor stroke were reassigned + expect(circle._flagStroke).toBe(false); + expect(circle._flagFill).toBe(false); + }); + }); + + describe('5. Prop Removal & Reset Semantics', () => { + it('restores Two.js default values when props are removed or set to undefined', () => { + const circleRef = React.createRef(); + + interface ShapeProps { + x?: number; + y?: number; + fill?: string; + stroke?: string; + linewidth?: number; + opacity?: number; + } + + function App({ shapeProps }: { shapeProps: ShapeProps }) { + return ( + + + + ); + } + + const { rerender } = render( + + ); + + const circle = circleRef.current!; + expect(circle.translation.x).toBe(200); + expect(circle.translation.y).toBe(300); + expect(circle.fill).toBe('#ff0000'); + expect(circle.stroke).toBe('#00ff00'); + expect(circle.linewidth).toBe(10); + expect(circle.opacity).toBe(0.4); + + // Now remove fill, stroke, linewidth, opacity, and position + rerender(); + + expect(circle.translation.x).toBe(0); + expect(circle.translation.y).toBe(0); + expect(circle.fill).toBe(TWO_DEFAULT_PROPS.fill); + expect(circle.stroke).toBe(TWO_DEFAULT_PROPS.stroke); + expect(circle.linewidth).toBe(TWO_DEFAULT_PROPS.linewidth); + expect(circle.opacity).toBe(TWO_DEFAULT_PROPS.opacity); + }); + + it('restores default values on Text component when props are removed', () => { + const textRef = React.createRef(); + + function App({ value, size }: { value?: string; size?: number }) { + return ( + + + + ); + } + + const { rerender } = render(); + const text = textRef.current!; + + expect(text.value).toBe('Hello World'); + expect(text.size).toBe(32); + + // Remove size prop + rerender(); + expect(text.value).toBe('Hello World'); + expect(text.size).toBe(TWO_DEFAULT_PROPS.size); + + // Remove value prop + rerender(); + expect(text.value).toBe(TWO_DEFAULT_PROPS.value); + }); + }); + + describe('6. Construction-Only Prop Replacement', () => { + it('recreates instance safely in-place at the exact child index when construction props change', () => { + const groupRef = React.createRef(); + const circleRef = React.createRef(); + + function App({ resolution }: { resolution: number }) { + return ( + + + + + + + + ); + } + + const { rerender } = render(); + const group = groupRef.current!; + const oldCircle = circleRef.current!; + + expect(group.children[1]).toBe(oldCircle); + expect(oldCircle.fill).toBe('orange'); + + // Change construction prop (resolution 12 -> 36) + rerender(); + const newCircle = circleRef.current!; + + expect(newCircle).not.toBe(oldCircle); + expect(group.children.length).toBe(3); + // Replaced in-place at exact child index 1 between the two rectangles! + expect(group.children[1]).toBe(newCircle); + expect(newCircle.fill).toBe('orange'); + }); + }); + + describe('7. Strict Mode & Resource Lifecycle', () => { + it('handles StrictMode double-mount without leaking duplicate children or event handlers', () => { + const probe: { hitTestPoint?: (x: number, y: number) => boolean } = {}; + + function Probe() { + const { hitTestPoint } = useTwo(); + probe.hitTestPoint = hitTestPoint; + return null; + } + + const groupRef = React.createRef(); + + render( + + + + {}} + /> + + + + + ); + + const group = groupRef.current!; + expect(group.children.length).toBe(1); + expect(probe.hitTestPoint!(400, 300)).toBe(true); + expect(probe.hitTestPoint!(10, 10)).toBe(false); + }); + + it('unmounts cleanly and unregisters handlers', () => { + const probe: { hitTestPoint?: (x: number, y: number) => boolean } = {}; + + function Probe() { + const { hitTestPoint } = useTwo(); + probe.hitTestPoint = hitTestPoint; + return null; + } + + function App({ show }: { show: boolean }) { + return ( + + {show && ( + {}} + /> + )} + + + ); + } + + const { rerender } = render(); + expect(probe.hitTestPoint!(400, 300)).toBe(true); + + rerender(); + expect(probe.hitTestPoint!(400, 300)).toBe(false); + }); + + it('distinguishes owned resources from shared resources on unmount', () => { + // Create a shared Texture instance outside of the shape + const sharedTexture = new Two.Texture(); + let textureDisposed = false; + + // Wrap texture to detect if dispose was incorrectly called + (sharedTexture as unknown as { dispose?: () => void }).dispose = () => { + textureDisposed = true; + }; + + function App({ show }: { show: boolean }) { + return ( + + {show && } + + ); + } + + const { rerender } = render(); + // Unmount the shape + rerender(); + + // Shared texture must NOT be disposed + expect(textureDisposed).toBe(false); + }); + }); +}); From fd18b02bdb552f9f06b5f36eac6da2f53d970db2 Mon Sep 17 00:00:00 2001 From: Jono Brandel Date: Wed, 2 Sep 2026 20:30:37 -0700 Subject: [PATCH 2/4] fix(reconciliation): defer side effects to commit phase, collapse shallow SVG branch, and broaden Texture src type --- lib/SVG.tsx | 6 +-- lib/Texture.tsx | 10 +++- lib/useTwoObject.ts | 93 +++++++++++++++++++++++------------ tests/reconciliation.test.tsx | 49 ++++++++++++++++++ 4 files changed, 119 insertions(+), 39 deletions(-) diff --git a/lib/SVG.tsx b/lib/SVG.tsx index 85ab5c8..c6900c7 100644 --- a/lib/SVG.tsx +++ b/lib/SVG.tsx @@ -105,11 +105,7 @@ export const SVG = React.forwardRef( (loadedGroup: Instance, svgElement: SVGElement | SVGElement[]) => { if (!mounted) return; - if (shallow) { - svg.add(loadedGroup.children); - } else { - svg.add(loadedGroup.children); - } + svg.add(loadedGroup.children); const handleLoad = onLoadRef.current; if (handleLoad) { diff --git a/lib/Texture.tsx b/lib/Texture.tsx index 9d0ae58..d752aa6 100644 --- a/lib/Texture.tsx +++ b/lib/Texture.tsx @@ -13,11 +13,17 @@ export type TextureProps = | 'offset' | 'image'; +export type TextureSource = + | string + | HTMLImageElement + | HTMLCanvasElement + | HTMLVideoElement; + type ComponentProps = React.PropsWithChildren< { [K in Extract]?: Instance[K]; } & { - src?: string | HTMLImageElement | HTMLCanvasElement | HTMLVideoElement; + src?: TextureSource; } >; @@ -26,7 +32,7 @@ export type RefTexture = Instance; export const Texture = React.forwardRef( (props, forwardedRef) => { useTwoObject(props as Record, forwardedRef, { - factory: (p) => new Two.Texture(p.src as string | HTMLImageElement), + factory: (p) => new Two.Texture(p.src as TextureSource), constructionProps: ['src'], isSceneObject: false, }); diff --git a/lib/useTwoObject.ts b/lib/useTwoObject.ts index 92f997c..f5d5a57 100644 --- a/lib/useTwoObject.ts +++ b/lib/useTwoObject.ts @@ -165,6 +165,10 @@ export function useTwoObject< const defaultPropsRef = useRef>({}); const prevPropsRef = useRef>({}); const isInitialMountRef = useRef(true); + const pendingReplacementRef = useRef<{ + oldInstance: T; + newInstance: T; + } | null>(null); // Check if any construction-only prop changed let needsRecreation = instanceRef.current === null; @@ -177,28 +181,14 @@ export function useTwoObject< } } - // Create or recreate instance synchronously so it's ready during render / ref forwarding + // Create or recreate instance synchronously so it's ready during render / ref forwarding. + // Note: All scene-graph mutations and resource disposals are deferred to commit-phase layout effects. if (needsRecreation) { const oldInstance = instanceRef.current; const newInstance = config.factory(props); if (oldInstance) { - // In-place replacement in parent.children if attached - if (parent && isSceneObject) { - const children = parent.children as unknown as Array; - const idx = children.indexOf(oldInstance); - if (idx !== -1) { - children.splice(idx, 1, newInstance); - (newInstance as unknown as { parent?: Group }).parent = parent; - if (typeof (parent as unknown as { _flagOrder?: boolean })._flagOrder !== 'undefined') { - (parent as unknown as { _flagOrder: boolean })._flagOrder = true; - } - } - } - - // Cleanup old owned resources and unregister old event handlers - unregisterEventShape(oldInstance as unknown as Shape | Group); - config.disposeOwned?.(oldInstance); + pendingReplacementRef.current = { oldInstance, newInstance }; } instanceRef.current = newInstance; @@ -282,26 +272,68 @@ export function useTwoObject< } }); - // Scene-graph attachment and reparenting lifecycle + const configRef = useRef(config); + configRef.current = config; + + // Scene-graph attachment, in-place replacement, and reparenting lifecycle useLayoutEffect(() => { - if (!isSceneObject || !parent) return; + const pending = pendingReplacementRef.current; - const sceneItem = instance as unknown as Shape | Group; + if (pending && pending.newInstance === instance) { + pendingReplacementRef.current = null; + const { oldInstance } = pending; - if (attachChild) { - attachChild(sceneItem); - } else { - parent.add(sceneItem as unknown as Shape); + if (isSceneObject && parent) { + const children = parent.children as unknown as Array; + const idx = children.indexOf(oldInstance); + if (idx !== -1) { + // Replace in-place at the exact same child index + children.splice(idx, 1, instance); + (instance as unknown as { parent?: Group }).parent = parent; + if (typeof (parent as unknown as { _flagOrder?: boolean })._flagOrder !== 'undefined') { + (parent as unknown as { _flagOrder: boolean })._flagOrder = true; + } + } else { + // Fallback if not found in children: attach to current parent + const sceneItem = instance as unknown as Shape | Group; + if (attachChild) { + attachChild(sceneItem); + } else { + parent.add(sceneItem as unknown as Shape); + } + } + } + + // Cleanup old instance owned resources and event handlers in commit phase + unregisterEventShape(oldInstance as unknown as Shape | Group); + configRef.current.disposeOwned?.(oldInstance); + } else if (isSceneObject && parent) { + const sceneItem = instance as unknown as Shape | Group; + if (attachChild) { + attachChild(sceneItem); + } else { + parent.add(sceneItem as unknown as Shape); + } } return () => { - if (detachChild) { - detachChild(sceneItem); - } else { - parent.remove(sceneItem as unknown as Shape); + // If this instance is about to be replaced by a pending newInstance on the same parent, + // do not remove it here; the pending replacement will splice the new instance in-place. + const nextPending = pendingReplacementRef.current; + if (nextPending && nextPending.oldInstance === instance && isSceneObject) { + return; + } + + if (isSceneObject && parent) { + const sceneItem = instance as unknown as Shape | Group; + if (detachChild) { + detachChild(sceneItem); + } else { + parent.remove(sceneItem as unknown as Shape); + } } }; - }, [instance, parent, isSceneObject, attachChild, detachChild]); + }, [instance, parent, isSceneObject, attachChild, detachChild, unregisterEventShape]); // Sibling order registration (runs on every commit in document order) useLayoutEffect(() => { @@ -312,9 +344,6 @@ export function useTwoObject< } }); - const configRef = useRef(config); - configRef.current = config; - // Event handler registration and cleanup useEffect(() => { const eventItem = instance as unknown as Shape | Group; diff --git a/tests/reconciliation.test.tsx b/tests/reconciliation.test.tsx index 6fdc181..f45eee1 100644 --- a/tests/reconciliation.test.tsx +++ b/tests/reconciliation.test.tsx @@ -412,6 +412,55 @@ describe('Two.js Scene Graph Reconciliation (Issue #29)', () => { // Replaced in-place at exact child index 1 between the two rectangles! expect(group.children[1]).toBe(newCircle); expect(newCircle.fill).toBe('orange'); + + // Subsequent recreation (36 -> 48) also keeps the exact same position + rerender(); + const thirdCircle = circleRef.current!; + + expect(thirdCircle).not.toBe(newCircle); + expect(group.children.length).toBe(3); + expect(group.children[1]).toBe(thirdCircle); + }); + + it('defers instance replacement and disposal to commit phase rather than render phase', () => { + const groupRef = React.createRef(); + let renderPhaseChildrenLengthDuringRecreation = -1; + + function Observer({ resolution }: { resolution: number }) { + // Inspect parent group during render phase + const group = groupRef.current; + if (group && resolution > 12) { + renderPhaseChildrenLengthDuringRecreation = group.children.length; + } + return ( + + ); + } + + function App({ resolution }: { resolution: number }) { + return ( + + + + + + + + ); + } + + const { rerender } = render(); + const group = groupRef.current!; + expect(group.children.length).toBe(3); + + rerender(); + // During render phase of recreation, group.children was NOT mutated or duplicated + expect(renderPhaseChildrenLengthDuringRecreation).toBe(3); + expect(group.children.length).toBe(3); }); }); From 537335dc8982f1ed1e4996641042a53eed2b06fc Mon Sep 17 00:00:00 2001 From: Jono Brandel Date: Wed, 2 Sep 2026 20:41:48 -0700 Subject: [PATCH 3/4] fix(reconciliation): detach oldInstance on reparenting recreation and remove shallow from SVG deps --- lib/SVG.tsx | 8 +++-- lib/useTwoObject.ts | 56 ++++++++++++++++++++++++----------- tests/reconciliation.test.tsx | 35 ++++++++++++++++++++++ 3 files changed, 79 insertions(+), 20 deletions(-) diff --git a/lib/SVG.tsx b/lib/SVG.tsx index c6900c7..bc8ead0 100644 --- a/lib/SVG.tsx +++ b/lib/SVG.tsx @@ -42,7 +42,7 @@ export type RefSVG = Instance; export const SVG = React.forwardRef( (props, forwardedRef) => { const { two } = useTwo(); - const { src, content, onLoad, onError, shallow, ...restProps } = props; + const { src, content, onLoad, onError, ...restProps } = props; const onLoadRef = useRef(onLoad); const onErrorRef = useRef(onError); @@ -66,7 +66,9 @@ export const SVG = React.forwardRef( parentValue, sizeValue, renderChildren, - } = useTwoGroup(restProps, forwardedRef); + } = useTwoGroup(restProps, forwardedRef, { + specialProps: ['shallow'], + }); // Validate props useEffect(() => { @@ -155,7 +157,7 @@ export const SVG = React.forwardRef( lastLoadedSource.current = { two: null, key: null }; svg.remove(svg.children); }; - }, [two, src, content, shallow, svg]); + }, [two, src, content, svg]); return ( diff --git a/lib/useTwoObject.ts b/lib/useTwoObject.ts index f5d5a57..e1b4412 100644 --- a/lib/useTwoObject.ts +++ b/lib/useTwoObject.ts @@ -283,23 +283,40 @@ export function useTwoObject< pendingReplacementRef.current = null; const { oldInstance } = pending; - if (isSceneObject && parent) { - const children = parent.children as unknown as Array; - const idx = children.indexOf(oldInstance); - if (idx !== -1) { - // Replace in-place at the exact same child index - children.splice(idx, 1, instance); - (instance as unknown as { parent?: Group }).parent = parent; - if (typeof (parent as unknown as { _flagOrder?: boolean })._flagOrder !== 'undefined') { - (parent as unknown as { _flagOrder: boolean })._flagOrder = true; + if (isSceneObject) { + let replacedInPlace = false; + if (parent) { + const children = parent.children as unknown as Array; + const idx = children.indexOf(oldInstance); + if (idx !== -1) { + // Replace in-place at the exact same child index + children.splice(idx, 1, instance); + (instance as unknown as { parent?: Group }).parent = parent; + if (typeof (parent as unknown as { _flagOrder?: boolean })._flagOrder !== 'undefined') { + (parent as unknown as { _flagOrder: boolean })._flagOrder = true; + } + replacedInPlace = true; } - } else { - // Fallback if not found in children: attach to current parent - const sceneItem = instance as unknown as Shape | Group; - if (attachChild) { - attachChild(sceneItem); - } else { - parent.add(sceneItem as unknown as Shape); + } + + if (!replacedInPlace) { + // When a construction-prop change coincides with a parent change, + // oldInstance is still in the previous parent's children. + // Detach oldInstance from its previous parent so it is not orphaned. + const oldParent = (oldInstance as unknown as { parent?: Group }).parent; + if (oldParent) { + oldParent.remove(oldInstance as unknown as Shape); + (oldInstance as unknown as { parent?: Group }).parent = undefined; + } + + // Attach replacement to the current parent + if (parent) { + const sceneItem = instance as unknown as Shape | Group; + if (attachChild) { + attachChild(sceneItem); + } else { + parent.add(sceneItem as unknown as Shape); + } } } } @@ -320,7 +337,12 @@ export function useTwoObject< // If this instance is about to be replaced by a pending newInstance on the same parent, // do not remove it here; the pending replacement will splice the new instance in-place. const nextPending = pendingReplacementRef.current; - if (nextPending && nextPending.oldInstance === instance && isSceneObject) { + if ( + nextPending && + nextPending.oldInstance === instance && + isSceneObject && + (instance as unknown as { parent?: Group }).parent === parent + ) { return; } diff --git a/tests/reconciliation.test.tsx b/tests/reconciliation.test.tsx index f45eee1..dd20aa5 100644 --- a/tests/reconciliation.test.tsx +++ b/tests/reconciliation.test.tsx @@ -255,6 +255,41 @@ describe('Two.js Scene Graph Reconciliation (Issue #29)', () => { expect(g2.children).toContain(circle); expect(circle.parent).toBe(g2); }); + + it('detaches old instance from previous parent when recreation coincides with parent change', () => { + const circleRef = React.createRef(); + const g1 = new Two.Group(); + const g2 = new Two.Group(); + + function App({ parentGroup, resolution }: { parentGroup: Two.Group; resolution: number }) { + return ( + + + + + + ); + } + + const { rerender } = render(); + const oldCircle = circleRef.current!; + + expect(g1.children).toContain(oldCircle); + expect(g2.children).not.toContain(oldCircle); + expect(oldCircle.parent).toBe(g1); + + // Simultaneously change parent to g2 AND change construction-only prop resolution (12 -> 36) + rerender(); + const newCircle = circleRef.current!; + + expect(newCircle).not.toBe(oldCircle); + // Old circle must be cleanly detached from g1 and not orphaned! + expect(g1.children).not.toContain(oldCircle); + expect(g1.children).toHaveLength(0); + // New circle must be attached to g2 + expect(g2.children).toContain(newCircle); + expect(newCircle.parent).toBe(g2); + }); }); describe('4. Discrete Prop Updates', () => { From ca6c11e830456d1570dfe1f5ea49e44f46e39d20 Mon Sep 17 00:00:00 2001 From: Jono Brandel Date: Wed, 2 Sep 2026 21:25:55 -0700 Subject: [PATCH 4/4] feat(props): extended prop normalization, vector ergonomics, and default resets --- lib/Line.tsx | 32 +++-- lib/LinearGradient.tsx | 39 ++++-- lib/Properties.ts | 61 +++++++++- lib/RadialGradient.tsx | 39 ++++-- lib/main.ts | 4 + lib/reconciliation.ts | 17 +++ lib/useTwoObject.ts | 80 ++++++++++-- tests/reconciliation.test.tsx | 223 ++++++++++++++++++++++++++++++++++ 8 files changed, 450 insertions(+), 45 deletions(-) diff --git a/lib/Line.tsx b/lib/Line.tsx index 3f80db5..394c86b 100644 --- a/lib/Line.tsx +++ b/lib/Line.tsx @@ -2,18 +2,22 @@ import React from 'react'; import Two from 'two.js'; import type { Line as Instance } from 'two.js/src/shapes/line'; import { PathProps } from './Path'; -import { type EventHandlers } from './Properties'; +import { applyVector, type EventHandlers, type VectorProp } from './Properties'; import { useTwoObject } from './useTwoObject'; export type LineProps = PathProps | 'left' | 'right'; type ComponentProps = React.PropsWithChildren< { - [K in Extract]?: Instance[K]; + [K in Extract]?: K extends 'left' | 'right' + ? VectorProp + : Instance[K]; } & { x1?: number; y1?: number; x2?: number; y2?: number; + left?: VectorProp; + right?: VectorProp; } & Partial >; @@ -23,31 +27,45 @@ export const Line = React.forwardRef( (props, forwardedRef) => { useTwoObject(props, forwardedRef, { factory: () => new Two.Line(), - specialProps: ['x1', 'y1', 'x2', 'y2'], + specialProps: ['x1', 'y1', 'x2', 'y2', 'left', 'right'], applySpecialProps: (line, currentProps, changed, removed) => { + if ('left' in changed) { + applyVector(line.left, currentProps.left); + } + if ('right' in changed) { + applyVector(line.right, currentProps.right); + } + if ('x1' in changed) { line.left.x = typeof currentProps.x1 === 'number' ? currentProps.x1 : 0; - } else if (removed.includes('x1')) { + } else if (removed.includes('x1') && !('left' in currentProps)) { line.left.x = 0; } if ('y1' in changed) { line.left.y = typeof currentProps.y1 === 'number' ? currentProps.y1 : 0; - } else if (removed.includes('y1')) { + } else if (removed.includes('y1') && !('left' in currentProps)) { line.left.y = 0; } if ('x2' in changed) { line.right.x = typeof currentProps.x2 === 'number' ? currentProps.x2 : 0; - } else if (removed.includes('x2')) { + } else if (removed.includes('x2') && !('right' in currentProps)) { line.right.x = 0; } if ('y2' in changed) { line.right.y = typeof currentProps.y2 === 'number' ? currentProps.y2 : 0; - } else if (removed.includes('y2')) { + } else if (removed.includes('y2') && !('right' in currentProps)) { line.right.y = 0; } + + if (removed.includes('left') && !('x1' in currentProps) && !('y1' in currentProps)) { + line.left.set(0, 0); + } + if (removed.includes('right') && !('x2' in currentProps) && !('y2' in currentProps)) { + line.right.set(0, 0); + } }, }); diff --git a/lib/LinearGradient.tsx b/lib/LinearGradient.tsx index e3db007..86d6b29 100644 --- a/lib/LinearGradient.tsx +++ b/lib/LinearGradient.tsx @@ -1,19 +1,23 @@ import React from 'react'; import Two from 'two.js'; import type { LinearGradient as Instance } from 'two.js/src/effects/linear-gradient'; -import { GradientProps } from './Properties'; +import { applyVector, GradientProps, type VectorProp } from './Properties'; import { useTwoObject } from './useTwoObject'; export type LinearGradientProps = GradientProps | 'left' | 'right'; type ComponentProps = React.PropsWithChildren< { - [K in Extract]?: Instance[K]; + [K in Extract]?: K extends 'left' | 'right' + ? VectorProp + : Instance[K]; } & { x1?: number; y1?: number; x2?: number; y2?: number; + left?: VectorProp; + right?: VectorProp; } >; @@ -24,39 +28,48 @@ export const LinearGradient = React.forwardRef( useTwoObject(props as Record, forwardedRef, { factory: () => new Two.LinearGradient(), isSceneObject: false, - specialProps: ['x1', 'y1', 'x2', 'y2'], + specialProps: ['x1', 'y1', 'x2', 'y2', 'left', 'right'], applySpecialProps: (gradient, currentProps, changed, removed) => { - const p = currentProps as unknown as { - x1?: number; - y1?: number; - x2?: number; - y2?: number; - }; + const p = currentProps as unknown as ComponentProps; const grad = gradient as unknown as Instance; + if ('left' in changed) { + applyVector(grad.left, p.left); + } + if ('right' in changed) { + applyVector(grad.right, p.right); + } + if ('x1' in changed) { grad.left.x = typeof p.x1 === 'number' ? p.x1 : 0; - } else if (removed.includes('x1')) { + } else if (removed.includes('x1') && !('left' in p)) { grad.left.x = 0; } if ('y1' in changed) { grad.left.y = typeof p.y1 === 'number' ? p.y1 : 0; - } else if (removed.includes('y1')) { + } else if (removed.includes('y1') && !('left' in p)) { grad.left.y = 0; } if ('x2' in changed) { grad.right.x = typeof p.x2 === 'number' ? p.x2 : 0; - } else if (removed.includes('x2')) { + } else if (removed.includes('x2') && !('right' in p)) { grad.right.x = 0; } if ('y2' in changed) { grad.right.y = typeof p.y2 === 'number' ? p.y2 : 0; - } else if (removed.includes('y2')) { + } else if (removed.includes('y2') && !('right' in p)) { grad.right.y = 0; } + + if (removed.includes('left') && !('x1' in p) && !('y1' in p)) { + grad.left.set(0, 0); + } + if (removed.includes('right') && !('x2' in p) && !('y2' in p)) { + grad.right.set(0, 0); + } }, }); diff --git a/lib/Properties.ts b/lib/Properties.ts index f7609b9..9285691 100644 --- a/lib/Properties.ts +++ b/lib/Properties.ts @@ -36,12 +36,41 @@ export const GRADIENT_PROPERTIES = [ 'stops', ] as const; -export type OriginProp = +export type VectorProp = | Vector | { x?: number; y?: number } | readonly [number, number] | [number, number]; +export type OriginProp = VectorProp; + +export type ScaleProp = + | number + | Vector + | { x?: number; y?: number } + | readonly [number, number] + | [number, number]; + +/** + * Normalizes and applies vector coordinates onto a Two.js Vector instance. + */ +export function applyVector( + target: Vector, + source: VectorProp | undefined +): void { + if (typeof source === 'undefined') return; + + if (source instanceof Two.Vector) { + target.copy(source); + } else if (Array.isArray(source) && source.length >= 2) { + target.set(source[0], source[1]); + } else if (typeof source === 'object' && source !== null) { + const obj = source as { x?: number; y?: number }; + if (typeof obj.x === 'number') target.x = obj.x; + if (typeof obj.y === 'number') target.y = obj.y; + } +} + /** * Normalizes and applies origin coordinates onto a Two.js shape instance. */ @@ -62,4 +91,34 @@ export function applyOrigin( } } +/** + * Normalizes and applies scale onto a Two.js object. + * Protects against object assignments that would otherwise cause _matrix to produce NaN. + */ +export function applyScale( + instance: { scale: number | Vector }, + scale: ScaleProp | undefined +): void { + if (typeof scale === 'undefined') return; + if (typeof scale === 'number') { + instance.scale = scale; + } else if (scale instanceof Two.Vector) { + instance.scale = scale; + } else if (Array.isArray(scale) && scale.length >= 2) { + if (instance.scale instanceof Two.Vector) { + instance.scale.set(scale[0], scale[1]); + } else { + instance.scale = new Two.Vector(scale[0], scale[1]); + } + } else if (typeof scale === 'object' && scale !== null) { + const obj = scale as { x?: number; y?: number }; + const sx = typeof obj.x === 'number' ? obj.x : 1; + const sy = typeof obj.y === 'number' ? obj.y : sx; + if (instance.scale instanceof Two.Vector) { + instance.scale.set(sx, sy); + } else { + instance.scale = new Two.Vector(sx, sy); + } + } +} diff --git a/lib/RadialGradient.tsx b/lib/RadialGradient.tsx index d0f9fd1..4c638bd 100644 --- a/lib/RadialGradient.tsx +++ b/lib/RadialGradient.tsx @@ -1,19 +1,23 @@ import React from 'react'; import Two from 'two.js'; import type { RadialGradient as Instance } from 'two.js/src/effects/radial-gradient'; -import { GradientProps } from './Properties'; +import { applyVector, GradientProps, type VectorProp } from './Properties'; import { useTwoObject } from './useTwoObject'; export type RadialGradientProps = GradientProps | 'center' | 'radius' | 'focal'; type ComponentProps = React.PropsWithChildren< { - [K in Extract]?: Instance[K]; + [K in Extract]?: K extends 'center' | 'focal' + ? VectorProp + : Instance[K]; } & { x?: number; y?: number; focalX?: number; focalY?: number; + center?: VectorProp; + focal?: VectorProp; } >; @@ -24,39 +28,48 @@ export const RadialGradient = React.forwardRef( useTwoObject(props as Record, forwardedRef, { factory: () => new Two.RadialGradient(), isSceneObject: false, - specialProps: ['x', 'y', 'focalX', 'focalY'], + specialProps: ['x', 'y', 'focalX', 'focalY', 'center', 'focal'], applySpecialProps: (gradient, currentProps, changed, removed) => { - const p = currentProps as unknown as { - x?: number; - y?: number; - focalX?: number; - focalY?: number; - }; + const p = currentProps as unknown as ComponentProps; const grad = gradient as unknown as Instance; + if ('center' in changed) { + applyVector(grad.center, p.center); + } + if ('focal' in changed) { + applyVector(grad.focal, p.focal); + } + if ('x' in changed) { grad.center.x = typeof p.x === 'number' ? p.x : 0; - } else if (removed.includes('x')) { + } else if (removed.includes('x') && !('center' in p)) { grad.center.x = 0; } if ('y' in changed) { grad.center.y = typeof p.y === 'number' ? p.y : 0; - } else if (removed.includes('y')) { + } else if (removed.includes('y') && !('center' in p)) { grad.center.y = 0; } if ('focalX' in changed) { grad.focal.x = typeof p.focalX === 'number' ? p.focalX : 0; - } else if (removed.includes('focalX')) { + } else if (removed.includes('focalX') && !('focal' in p)) { grad.focal.x = 0; } if ('focalY' in changed) { grad.focal.y = typeof p.focalY === 'number' ? p.focalY : 0; - } else if (removed.includes('focalY')) { + } else if (removed.includes('focalY') && !('focal' in p)) { grad.focal.y = 0; } + + if (removed.includes('center') && !('x' in p) && !('y' in p)) { + grad.center.set(0, 0); + } + if (removed.includes('focal') && !('focalX' in p) && !('focalY' in p)) { + grad.focal.set(0, 0); + } }, }); diff --git a/lib/main.ts b/lib/main.ts index 32caabf..e55e796 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -47,12 +47,16 @@ export type { ShapeProps, GradientProps, OriginProp, + VectorProp, + ScaleProp, } from './Properties'; export { ELEMENT_PROPERTIES, SHAPE_PROPERTIES, GRADIENT_PROPERTIES, applyOrigin, + applyVector, + applyScale, } from './Properties'; // Property matrix exports diff --git a/lib/reconciliation.ts b/lib/reconciliation.ts index d1e4785..36c4c7d 100644 --- a/lib/reconciliation.ts +++ b/lib/reconciliation.ts @@ -27,6 +27,23 @@ export const TWO_DEFAULT_PROPS: Record = { y: 0, rotation: 0, scale: 1, + skewX: 0, + skewY: 0, + + // Masking & clipping + mask: null, + clip: false, + strokeAttenuation: true, + + // General element + className: '', + + // Points defaults + sizeAttenuation: false, + + // Gradient defaults + spread: 'pad', + units: 'objectBoundingBox', // Text defaults value: '', diff --git a/lib/useTwoObject.ts b/lib/useTwoObject.ts index e1b4412..aa448a5 100644 --- a/lib/useTwoObject.ts +++ b/lib/useTwoObject.ts @@ -10,6 +10,7 @@ import React, { import Two from 'two.js'; import type { Shape } from 'two.js/src/shape'; import type { Group } from 'two.js/src/group'; +import type { Vector } from 'two.js/src/vector'; import { ChildSlotContext, useTwo, @@ -18,6 +19,12 @@ import { type TwoSizeContextValue, } from './Context'; import { EVENT_HANDLER_NAMES, type EventHandlers } from './Events'; +import { + applyScale, + applyVector, + type ScaleProp, + type VectorProp, +} from './Properties'; import { captureDefaultProps, diffProps, @@ -77,30 +84,59 @@ export interface TwoObjectConfig< isSceneObject?: boolean; } +export interface PositionProps { + x?: number; + y?: number; + position?: VectorProp; + translation?: VectorProp; +} + /** - * Standard handler for applying position (x, y) to translation. + * Standard handler for applying position (x, y, position, translation) to translation. */ export function applyDefaultPositionProps( instance: T, - props: { x?: number; y?: number }, - changed: { x?: number; y?: number }, + props: PositionProps, + changed: PositionProps, removed: Array ): void { const inst = instance as unknown as Record; if ('translation' in inst && inst.translation) { - const translation = inst.translation as { x: number; y: number }; + const translation = inst.translation as Vector; + + // Check position / translation vector props first + if ('position' in changed && changed.position !== undefined) { + applyVector(translation, changed.position); + } else if ('translation' in changed && changed.translation !== undefined) { + applyVector(translation, changed.translation); + } + + const hasExplicitPos = props.position !== undefined && props.position !== null; + const hasExplicitTrans = props.translation !== undefined && props.translation !== null; + // Individual x, y props take precedence or apply discretely if ('x' in changed) { translation.x = typeof props.x === 'number' ? props.x : 0; - } else if (removed.includes('x')) { + } else if (removed.includes('x') && !hasExplicitPos && !hasExplicitTrans) { translation.x = 0; } if ('y' in changed) { translation.y = typeof props.y === 'number' ? props.y : 0; - } else if (removed.includes('y')) { + } else if (removed.includes('y') && !hasExplicitPos && !hasExplicitTrans) { translation.y = 0; } + + // If position or translation was removed and no explicit x or y is set + if ( + (removed.includes('position') || removed.includes('translation')) && + typeof props.x !== 'number' && + typeof props.y !== 'number' && + !hasExplicitPos && + !hasExplicitTrans + ) { + translation.set(0, 0); + } } } @@ -135,7 +171,15 @@ export function useTwoObject< const isSceneObject = config.isSceneObject !== false; const constructionProps = config.constructionProps ?? []; const specialPropsSet = useMemo( - () => new Set(['x', 'y', ...(config.specialProps as string[] ?? [])]), + () => + new Set([ + 'x', + 'y', + 'position', + 'translation', + 'scale', + ...((config.specialProps as string[]) ?? []), + ]), [config.specialProps] ); @@ -249,15 +293,29 @@ export function useTwoObject< } } - // 3. Handle default position props (x, y) + // 3. Handle default position props (x, y, position, translation) applyDefaultPositionProps( instance, - props as { x?: number; y?: number }, - changed as { x?: number; y?: number }, + props as PositionProps, + changed as PositionProps, removed as string[] ); - // 4. Custom special props callback + // 4. Handle scale prop safely + if ('scale' in changed) { + applyScale( + instance as unknown as { scale: number | Vector }, + (changed as { scale?: ScaleProp }).scale + ); + } else if (removed.includes('scale')) { + const defaultScale = (defaultPropsRef.current.scale ?? 1) as ScaleProp; + applyScale( + instance as unknown as { scale: number | Vector }, + defaultScale + ); + } + + // 5. Custom special props callback if (config.applySpecialProps) { config.applySpecialProps( instance, diff --git a/tests/reconciliation.test.tsx b/tests/reconciliation.test.tsx index dd20aa5..4837b5b 100644 --- a/tests/reconciliation.test.tsx +++ b/tests/reconciliation.test.tsx @@ -9,11 +9,19 @@ import { Rectangle, Polygon, Text, + Line, + LinearGradient, + RadialGradient, useTwo, type RefCircle, type RefGroup, type RefRectangle, type RefPolygon, + type RefLine, + type RefLinearGradient, + type RefRadialGradient, + type VectorProp, + type ScaleProp, } from '../lib/main'; import { TwoParentContext } from '../lib/Context'; import { TWO_DEFAULT_PROPS } from '../lib/reconciliation'; @@ -591,4 +599,219 @@ describe('Two.js Scene Graph Reconciliation (Issue #29)', () => { expect(textureDisposed).toBe(false); }); }); + + describe('9. Extended Prop Normalization and Default Resets', () => { + it('normalizes position and translation props (objects, tuples, Two.Vector) without throwing', () => { + const circleRef = React.createRef(); + + function App({ + position, + translation, + }: { + position?: VectorProp; + translation?: VectorProp; + }) { + return ( + + + + ); + } + + // Object literal + const { rerender } = render(); + const circle = circleRef.current!; + expect(circle.translation.x).toBe(120); + expect(circle.translation.y).toBe(80); + + // Tuple + rerender(); + expect(circle.translation.x).toBe(45); + expect(circle.translation.y).toBe(65); + + // Two.Vector + const v = new Two.Vector(200, 300); + rerender(); + expect(circle.translation.x).toBe(200); + expect(circle.translation.y).toBe(300); + + // Removal resets to (0, 0) + rerender(); + expect(circle.translation.x).toBe(0); + expect(circle.translation.y).toBe(0); + }); + + it('normalizes scale tuples and objects safely without matrix NaN', () => { + const circleRef = React.createRef(); + + function App({ scale }: { scale?: ScaleProp }) { + return ( + + + + ); + } + + // Tuple scale + const { rerender } = render(); + const circle = circleRef.current!; + expect(circle.scale).toBeInstanceOf(Two.Vector); + expect((circle.scale as Two.Vector).x).toBe(2); + expect((circle.scale as Two.Vector).y).toBe(3); + + // Ensure _matrix update does not produce NaN + const internalCircle = circle as unknown as { + _update: () => void; + _matrix: { elements: Float32Array }; + }; + internalCircle._update(); + for (const el of internalCircle._matrix.elements) { + expect(Number.isNaN(el)).toBe(false); + } + + // Object scale + rerender(); + expect((circle.scale as Two.Vector).x).toBe(0.5); + expect((circle.scale as Two.Vector).y).toBe(1.5); + internalCircle._update(); + for (const el of internalCircle._matrix.elements) { + expect(Number.isNaN(el)).toBe(false); + } + + // Scalar number scale + rerender(); + expect(circle.scale).toBe(4); + + // Removal resets to default scale 1 + rerender(); + expect(circle.scale).toBe(1); + }); + + it('resets removed properties to TWO_DEFAULT_PROPS (mask, clip, strokeAttenuation, skew)', () => { + const circleRef = React.createRef(); + const maskShape = new Two.Rectangle(0, 0, 50, 50); + + function App({ + mask, + clip, + strokeAttenuation, + skewX, + skewY, + }: { + mask?: Two.Shape | null; + clip?: boolean; + strokeAttenuation?: boolean; + skewX?: number; + skewY?: number; + }) { + return ( + + + + ); + } + + const { rerender } = render( + + ); + const circle = circleRef.current!; + expect(circle.mask).toBe(maskShape); + expect(circle.clip).toBe(true); + expect(circle.strokeAttenuation).toBe(false); + expect(circle.skewX).toBe(0.5); + expect(circle.skewY).toBe(0.25); + + // Remove all props -> must reset to Two.js defaults! + rerender(); + expect(circle.mask).toBeNull(); + expect(circle.clip).toBe(false); + expect(circle.strokeAttenuation).toBe(true); + expect(circle.skewX).toBe(0); + expect(circle.skewY).toBe(0); + }); + + it('normalizes vector endpoints on Line, LinearGradient, and RadialGradient', () => { + const lineRef = React.createRef(); + const linearRef = React.createRef(); + const radialRef = React.createRef(); + + function App({ + lineLeft, + lineRight, + linearLeft, + linearRight, + radialCenter, + radialFocal, + }: { + lineLeft?: VectorProp; + lineRight?: VectorProp; + linearLeft?: VectorProp; + linearRight?: VectorProp; + radialCenter?: VectorProp; + radialFocal?: VectorProp; + }) { + return ( + + + + + + ); + } + + const { rerender } = render( + + ); + + const line = lineRef.current!; + expect(line.left.x).toBe(10); + expect(line.left.y).toBe(20); + expect(line.right.x).toBe(100); + expect(line.right.y).toBe(200); + + const linear = linearRef.current!; + expect(linear.left.x).toBe(5); + expect(linear.left.y).toBe(10); + expect(linear.right.x).toBe(80); + expect(linear.right.y).toBe(90); + + const radial = radialRef.current!; + expect(radial.center.x).toBe(40); + expect(radial.center.y).toBe(50); + expect(radial.focal.x).toBe(20); + expect(radial.focal.y).toBe(30); + + // Removal resets + rerender(); + expect(line.left.x).toBe(0); + expect(line.left.y).toBe(0); + expect(line.right.x).toBe(0); + expect(line.right.y).toBe(0); + expect(linear.left.x).toBe(0); + expect(linear.left.y).toBe(0); + expect(radial.center.x).toBe(0); + expect(radial.center.y).toBe(0); + }); + }); });