diff --git a/README.md b/README.md index 4254075..8a38a9c 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,28 @@ function App() { > > ``` +Canvas accepts `svg` / `SVGRenderer`, `canvas` / `CanvasRenderer`, and +`webgl` / `WebGLRenderer`. The default is SVG. `width` and `height` update the +existing renderer; changing `autostart` plays or pauses the existing instance. +`type`, `fullscreen`, `ratio`, `overdraw`, and `smoothing` are construction +options. Change the Canvas `key` to apply new values and recreate its scene. +Changing these options without a remount warns in development. + +```tsx + + + +``` + +When sizing SVG with CSS, use a `viewBox` to scale the artwork with its viewport: + +```tsx + + + +``` + ### Adding Shapes All Two.js primitives are available as React components: @@ -278,6 +300,61 @@ All Two.js properties work as React props: /> ``` +### Pointer events and dragging + +Shapes and Groups accept click, double-click, context-menu, wheel, and pointer +handlers. Every `TwoEvent.point` is in **renderer world coordinates**, measured +in logical pixels from the top-left origin, before shape or Group transforms. +Canvas/WebGL CSS sizing and pixel ratio are accounted for; SVG uses its screen +transform, including `viewBox` and `preserveAspectRatio`. Bubbling and capture +use the same coordinates. To obtain target-local coordinates, apply the inverse +of the target's `worldMatrix` to `event.point`. + +The topmost interactive geometry follows the current complete Two.js scene +order. `event.target` is the hit leaf shape, including when only its Group has +handlers. `event.currentTarget` is the shape or Group running the handler. +Handlers bubble through all ancestor Groups; `stopPropagation()` stops that +dispatch. Noninteractive shapes do not block interactive shapes underneath. + +A Group is interactive through its descendants' geometry, so empty space in a +sparse Group does not hit. Invisible or zero-opacity shapes and ancestors, +`clip` shapes, and paths with neither visible fill nor stroke are excluded. +Masks restrict hits to their geometry in the owner's local space. Geometry +precision and fill/stroke tests otherwise follow Two.js `contains()` behavior. +Pointer over/out bubble once per target transition; enter/leave run once for +each entered or exited ancestor, without bubbling again. + +Use the event's capture methods for dragging. Capture belongs to +`currentTarget`, is independent for each pointer, and routes subsequent move, +up, and cancel events through that target's ancestors even outside the +renderer. Release occurs automatically on up, cancel, lost native capture, +removal of the last handler, or unmount; it can also be explicit. + +```tsx + { + event.setPointerCapture((event.nativeEvent as PointerEvent).pointerId); + }} + onPointerMove={(event) => { + const id = (event.nativeEvent as PointerEvent).pointerId; + if (event.hasPointerCapture(id)) { + // This example has no transformed parent Group. + event.currentTarget.translation.set(event.point.x, event.point.y); + } + }} + onPointerUp={(event) => { + event.releasePointerCapture((event.nativeEvent as PointerEvent).pointerId); + }} +/> +``` + +`Canvas.onPointerMissed` runs once on pointer-up for a complete primary-button +press that both began and ended outside interactive geometry. A hit at either +end, any capture during the press, cancellation, a nonprimary/right-button +press, or an up without a preceding down suppresses it. The subsequent click +does not fire a second missed notification; click handlers use the geometry +under the click independently of pointer capture. + ## TypeScript Full TypeScript support with ref types for all components: @@ -412,6 +489,24 @@ function AnimatedIcon() { - `onError(error)` — Callback when loading fails - All Two.js Group properties (fill, stroke, opacity, etc.) +`shallow` (default `false`) removes the imported top-level SVG Group and places +its children directly in the component's stable wrapper. Nested `` groups +remain intact. This mirrors Two.js shallow interpretation: root SVG transforms +are discarded with that top-level Group, so leave `shallow` off when preserving +root transforms matters. Changing `src`, `content`, or `shallow` replaces only +the imported artwork; JSX children remain attached after the imports in scene +order and stay visible during loading. + +URL sources use `fetch` and an `AbortSignal`, including URLs with query strings. +Source replacement and unmount abort pending requests, ignore stale responses, +and release imported resources. Inline content must be a well-formed SVG +document. Successful loads apply declarative styles before invoking the latest +`onLoad` callback once per load; network, HTTP, and parsing failures invoke +`onError`. Cancellation does not invoke either callback. Updating callbacks +does not reload the artwork. User callback exceptions are logged separately +from loading errors. If both sources are supplied at runtime, `src` wins with +a warning; missing sources report an error. + > [!NOTE] > The SVG component uses Two.js's `load()` method which supports a subset of SVG 1.1 features. Complex SVG features like filters, animations (SMIL), and some advanced elements may not be fully supported. Refer to [Two.js SVG documentation](https://two.js.org/docs/two/#two-interpret) for details on supported features. @@ -453,6 +548,12 @@ npm test npm run lint ``` +For real renderer and native pointer-capture checks, run `npm run dev` and open +`http://localhost:3000/tests/browser/`. The harness checks every renderer +spelling with real SVG, Canvas, and WebGL, then offers drag checks for native +capture, bubbling, and release outside the renderer. The unit tests mock the +WebGL constructor because jsdom does not provide a GPU context. + The development server runs the documentation site which imports the library components directly from the `lib/` directory, allowing you to see changes in real-time. ## Acknowledgments diff --git a/lib/Events.ts b/lib/Events.ts index 79e9f3d..ffcf431 100644 --- a/lib/Events.ts +++ b/lib/Events.ts @@ -7,6 +7,10 @@ export interface TwoEvent { target: T; currentTarget: T; point: { x: number; y: number }; + /** Capture this pointer for currentTarget until release, up, or cancel. */ + setPointerCapture: (pointerId: number) => void; + releasePointerCapture: (pointerId: number) => void; + hasPointerCapture: (pointerId: number) => boolean; stopPropagation: () => void; readonly stopped: boolean; } @@ -51,49 +55,51 @@ export interface EventShape { parent?: Group; } -/** - * Convert DOM event coordinates to canvas-relative coordinates (center origin) - */ -export function getCanvasCoordinates( - nativeEvent: PointerEvent | MouseEvent | WheelEvent, - canvas: HTMLElement, - two: Two -): { x: number; y: number } { - const rect = canvas.getBoundingClientRect(); - - // Convert from DOM space (top-left origin) to Two.js space (center origin) - const x = nativeEvent.clientX - rect.left - two.width / 2; - const y = nativeEvent.clientY - rect.top - two.height / 2; - - return { x, y }; -} - -/** - * Convert raw client coordinates to world-space coordinates for hit testing. - * World space uses a top-left origin, relative to the canvas element. - */ +/** Renderer world coordinates, in logical pixels from the top-left origin. */ export function clientToWorldPoint( clientX: number, clientY: number, - canvas: HTMLElement + canvas: HTMLElement, + two?: Two | null, ): { x: number; y: number } { + // SVG viewport sizing does not scale its contents unless a viewBox (or CSS + // transform) does so. Its screen CTM also accounts for preserveAspectRatio. + const svg = canvas as HTMLElement & { getScreenCTM?: () => DOMMatrix | null }; + const screen = svg.getScreenCTM?.(); + if (screen) { + const inverse = screen.inverse(); + return { + x: inverse.a * clientX + inverse.c * clientY + inverse.e, + y: inverse.b * clientX + inverse.d * clientY + inverse.f, + }; + } const rect = canvas.getBoundingClientRect(); - return { - x: clientX - rect.left, - y: clientY - rect.top, + x: (clientX - rect.left) * (two && rect.width > 0 ? two.width / rect.width : 1), + y: (clientY - rect.top) * (two && rect.height > 0 ? two.height / rect.height : 1), }; } -/** - * Convert DOM event coordinates to world-space coordinates for hit testing - * World-space uses top-left origin (same as DOM but relative to canvas) - */ +export function getCanvasCoordinates( + event: PointerEvent | MouseEvent | WheelEvent, + canvas: HTMLElement, + two: Two, +): { x: number; y: number } { + return clientToWorldPoint(event.clientX, event.clientY, canvas, two); +} + export function getWorldCoordinates( - nativeEvent: PointerEvent | MouseEvent, - canvas: HTMLElement + event: PointerEvent | MouseEvent | WheelEvent, + canvas: HTMLElement, + two?: Two | null, ): { x: number; y: number } { - return clientToWorldPoint(nativeEvent.clientX, nativeEvent.clientY, canvas); + return clientToWorldPoint(event.clientX, event.clientY, canvas, two); +} + +export interface PointerCaptureControls { + setPointerCapture: (pointerId: number) => void; + releasePointerCapture: (pointerId: number) => void; + hasPointerCapture: (pointerId: number) => boolean; } /** @@ -103,7 +109,8 @@ export function createTwoEvent( nativeEvent: PointerEvent | MouseEvent | WheelEvent, target: T, currentTarget: T, - point: { x: number; y: number } + point: { x: number; y: number }, + capture?: PointerCaptureControls, ): TwoEvent { let stopped = false; @@ -112,6 +119,9 @@ export function createTwoEvent( target, currentTarget, point, + setPointerCapture: (id) => capture?.setPointerCapture(id), + releasePointerCapture: (id) => capture?.releasePointerCapture(id), + hasPointerCapture: (id) => capture?.hasPointerCapture(id) ?? false, stopPropagation: () => { stopped = true; }, @@ -121,146 +131,106 @@ export function createTwoEvent( }; } -/** - * Check if a shape contains a point using Two.js hit testing - */ -export function hitTest(shape: Shape | Group, x: number, y: number, two?: Two | null): boolean { - // Check if shape is visible - if ('visible' in shape && !shape.visible) { - return false; - } - - // Use shape.contains if custom contains function exists. - // DOM nodes also expose a `contains`, with completely different semantics, - // so exclude those. The globals are guarded because this module must not - // throw when evaluated outside a DOM runtime. - const candidateShape = shape as unknown as { contains?: unknown }; - const isDomContains = - (typeof Node !== 'undefined' && - candidateShape.contains === Node.prototype.contains) || - (typeof Element !== 'undefined' && - candidateShape.contains === Element.prototype.contains); - if (typeof candidateShape.contains === 'function' && !isDomContains) { - return (candidateShape.contains as (x: number, y: number) => boolean)(x, y); - } - - // Use Two.js getBoundingClientRect API - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (typeof (shape as any).getBoundingClientRect === 'function') { - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const rect = (shape as any).getBoundingClientRect(false); - if (rect && typeof rect.left === 'number' && typeof rect.right === 'number') { - const isRealTwoShape = 'worldMatrix' in shape || '_matrix' in shape; - const offsetX = (!isRealTwoShape && two) ? two.width / 2 : 0; - const offsetY = (!isRealTwoShape && two) ? two.height / 2 : 0; - const left = rect.left + offsetX; - const right = rect.right + offsetX; - const top = rect.top + offsetY; - const bottom = rect.bottom + offsetY; - - if (x >= left && x <= right && y >= top && y <= bottom) { - return true; - } - } - } catch { - // Fallback to checking children if getBoundingClientRect fails - } +/** Test leaf geometry; groups are the union of their descendants. */ +export function hitTest(shape: Shape | Group, x: number, y: number): boolean { + if ('visible' in shape && !shape.visible) return false; + if ('opacity' in shape && (shape as Group).opacity <= 0) return false; + if ('children' in shape) { + return Array.from((shape as Group).children).some((child) => hitTest(child, x, y)); } + const candidate = shape as Shape & { contains?: (x: number, y: number) => boolean }; + if (typeof candidate.contains === 'function') return candidate.contains(x, y); + const rect = (shape as Shape & { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect?.(); + return !!rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} - // For Groups without bounds, recursively check children - if ('children' in shape && Array.isArray((shape as Group).children)) { - for (const child of (shape as Group).children) { - if (hitTest(child, x, y, two)) { - return true; - } - } +/** Walk the actual scene ancestry, including groups without handlers. */ +export function getParentHierarchy( + shape: Shape | Group, + shapes: Map, +): Array { + const hierarchy: Array = []; + const visited = new Set(); + let current: Shape | Group | undefined = shape; + while (current && !visited.has(current)) { + visited.add(current); + hierarchy.push(current); + current = (current as Shape & { parent?: Group }).parent ?? shapes.get(current)?.parent; } - - return false; + return hierarchy; } -/** - * Sort shapes front-to-back (topmost visible shape first). - * In 2D rendering, shapes drawn later (or with higher parent.children index) sit on top of shapes drawn earlier. - */ +/** Compare complete scene paths, including nested and reordered groups. */ export function sortFrontToBack( hits: Array, - shapes: Map + shapes: Map, ): Array { - if (hits.length <= 1) return hits; - - const keys = Array.from(shapes.keys()); - return [...hits].sort((a, b) => { - const entryA = shapes.get(a); - const entryB = shapes.get(b); + const pathA = getParentHierarchy(a, shapes).reverse(); + const pathB = getParentHierarchy(b, shapes).reverse(); + let i = 0; + while (i < pathA.length && i < pathB.length && pathA[i] === pathB[i]) i++; + if (i > 0 && i < pathA.length && i < pathB.length) { + const siblings = (pathA[i - 1] as Group).children; + return siblings.indexOf(pathB[i]) - siblings.indexOf(pathA[i]); + } + if (i > 0) return pathB.length - pathA.length; + return 0; + }); +} - // If both belong to the same parent Group, compare their index in parent.children - if (entryA?.parent && entryB?.parent && entryA.parent === entryB.parent) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const parentChildren = (entryA.parent as any).children; - if (parentChildren && typeof parentChildren.indexOf === 'function') { - const indexA = parentChildren.indexOf(a); - const indexB = parentChildren.indexOf(b); - if (indexA !== -1 && indexB !== -1) { - return indexB - indexA; // Higher index = drawn on top = should be first - } - } +function passesMasks(shape: Shape | Group, x: number, y: number): boolean { + let current: Shape | Group | undefined = shape; + while (current) { + const owner = current as Shape & { mask?: Shape | Group; clip?: boolean }; + if (owner.clip) return false; + if (owner.mask) { + // Masks are rendered in their owner's local space, even if unattached. + const inverse = owner.worldMatrix.inverse(); + if (!inverse) return false; + const local = inverse.multiply(x, y, 1); + const maskParent = (owner.mask as Shape & { parent?: Group }).parent; + const point = maskParent?.worldMatrix.multiply(local[0], local[1], 1) ?? local; + if (!maskContains(owner.mask, point[0], point[1])) return false; } + current = (current as Shape & { parent?: Group }).parent; + } + return true; +} - // Default: reverse registration order (shapes registered later sit on top) - const indexA = keys.indexOf(a); - const indexB = keys.indexOf(b); - return indexB - indexA; // Higher registration index = frontmost - }); +function maskContains(mask: Shape | Group, x: number, y: number): boolean { + if ('children' in mask) { + return Array.from((mask as Group).children).some((child) => maskContains(child, x, y)); + } + const geometry = mask as Shape & { + contains: (x: number, y: number, options: Record) => boolean; + }; + return geometry.contains(x, y, { ignoreVisibility: true, fill: true, stroke: false }); } -/** - * Get all shapes at a point, sorted by depth (front to back) - * Uses scene graph traversal and registration order to maintain z-order - */ +function hasPaint(shape: Shape | Group): boolean { + if (!('fill' in shape) && !('stroke' in shape)) return true; + const painted = shape as Shape & { fill?: unknown; stroke?: unknown; linewidth?: number }; + const visiblePaint = (paint: unknown) => !!paint && + (typeof paint !== 'string' || !/^(none|transparent)$/i.test(paint)); + return visiblePaint(painted.fill) || ((painted.linewidth ?? 1) > 0 && visiblePaint(painted.stroke)); +} + +/** Two.js traversal supplies authoritative draw order; groups never use bounds. */ export function getShapesAtPoint( shapes: Map, x: number, y: number, - two?: Two | null + two?: Two | null, ): Array { if (two?.scene) { (two.scene as unknown as { _update: (deep?: boolean) => void })._update(true); + const scene = two.scene as Group & { + getShapesAtPoint: (x: number, y: number) => Array; + }; + return Array.from(scene.getShapesAtPoint(x, y)).filter((shape) => + hasPaint(shape) && passesMasks(shape, x, y) && + getParentHierarchy(shape, shapes).some((parent) => shapes.has(parent))); } - - const hits: Array = []; - - for (const [shape] of shapes) { - if (hitTest(shape, x, y, two)) { - hits.push(shape); - } - } - - return sortFrontToBack(hits, shapes); -} - -/** - * Get the parent hierarchy for event bubbling - */ -export function getParentHierarchy( - shape: Shape | Group, - shapes: Map -): Array { - const hierarchy: Array = [shape]; - - // Walk up the parent chain - const entry = shapes.get(shape); - if (entry?.parent) { - let currentParent: Group | undefined = entry.parent; - - while (currentParent) { - hierarchy.push(currentParent); - const parentEntry = shapes.get(currentParent); - currentParent = parentEntry?.parent; - } - } - - return hierarchy; + return sortFrontToBack(Array.from(shapes.keys()).filter((shape) => hitTest(shape, x, y)), shapes); } diff --git a/lib/Provider.tsx b/lib/Provider.tsx index 2d326c9..0e6066a 100644 --- a/lib/Provider.tsx +++ b/lib/Provider.tsx @@ -24,7 +24,6 @@ import { clientToWorldPoint, createTwoEvent, getCanvasCoordinates, - getWorldCoordinates, getParentHierarchy, getShapesAtPoint, type EventShape, @@ -32,6 +31,24 @@ import { type TwoConstructorProps = ConstructorParameters[0]; +function normalizeRenderer(type: unknown): string { + switch (type) { + case 'canvas': return Two.Types.canvas; + case 'webgl': return Two.Types.webgl; + case 'svg': + case undefined: return Two.Types.svg; + default: return type as string; + } +} + +function isProduction(): boolean { + return (import.meta as ImportMeta & { env?: { PROD?: boolean } }).env?.PROD === true || + (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV === 'production'; +} + +const IMMUTABLE_OPTIONS = ['type', 'fullscreen', 'ratio', 'overdraw', 'smoothing'] as const; +const FULLSCREEN_BODY_STYLES = ['overflow', 'margin', 'padding', 'top', 'left', 'right', 'bottom', 'position'] as const; + // List of Two.js constructor prop keys const TWO_CONSTRUCTOR_KEYS = [ 'fullscreen', @@ -50,7 +67,7 @@ type TwoConstructorOnlyProps = { autostart?: boolean; width?: number; height?: number; - type?: (typeof Two.Types)[keyof typeof Two.Types]; + type?: 'canvas' | 'CanvasRenderer' | 'svg' | 'SVGRenderer' | 'webgl' | 'WebGLRenderer'; overdraw?: boolean; smoothing?: boolean; ratio?: number; @@ -83,10 +100,7 @@ type ComponentProps = React.PropsWithChildren< * Warns in development mode if DOM elements or incompatible components are found. */ function validateChildren(children: React.ReactNode): void { - if ( - (globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env - ?.NODE_ENV === 'production' - ) { + if (isProduction()) { return; } @@ -135,10 +149,16 @@ export const Provider = React.forwardRef< const { two, parent } = useTwo(); const container = useRef(null); const eventShapes = useRef>(new Map()); + const rendererElement = useRef(null); const hoveredShapes = useRef>(new Set()); - const capturedShape = useRef(null); + const capturedShapes = useRef>(new Map()); + const pointerDowns = useRef>(new Map()); + const hoverTarget = useRef(null); const childrenOrderRef = useRef>([]); const attachedChildrenRef = useRef>(new Set()); + const constructorOptions = useRef | null>(null); + const previousOptions = useRef>({}); + const onPointerMissed = props.onPointerMissed; const [twoState, setTwoState] = useState(two); const [parentState, setParentState] = useState(parent); @@ -187,6 +207,8 @@ export const Provider = React.forwardRef< } }); + twoProps.type = normalizeRenderer(twoProps.type); + twoProps.fullscreen = twoProps.fullscreen ?? false; return { twoProps, domProps }; }, [props]); @@ -213,8 +235,12 @@ export const Provider = React.forwardRef< const unregisterEventShape = useCallback((shape: Shape | Group) => { eventShapes.current.delete(shape); hoveredShapes.current.delete(shape); - if (capturedShape.current === shape) { - capturedShape.current = null; + for (const [id, captured] of capturedShapes.current) { + if (captured === shape) { + capturedShapes.current.delete(id); + const element = rendererElement.current; + if (element?.hasPointerCapture?.(id)) element.releasePointerCapture(id); + } } }, []); @@ -228,7 +254,7 @@ export const Provider = React.forwardRef< const canvas = twoState?.renderer.domElement; if (!canvas) return false; - const point = clientToWorldPoint(clientX, clientY, canvas); + const point = clientToWorldPoint(clientX, clientY, canvas, twoState); return ( getShapesAtPoint(eventShapes.current, point.x, point.y, twoState) .length > 0 @@ -242,10 +268,16 @@ export const Provider = React.forwardRef< const isRoot = !two; if (isRoot && container.current && container.current.parentElement) { + const bodyStyles = twoProps.fullscreen + ? FULLSCREEN_BODY_STYLES.map((key) => [key, document.body.style[key]] as const) + : []; // Only update root instance const two = new Two(twoProps as TwoConstructorProps).appendTo( container.current.parentElement, ); + constructorOptions.current = { ...twoProps }; + previousOptions.current = { ...twoProps }; + rendererElement.current = two.renderer.domElement; setTwoState(two); setParentState(two.scene); @@ -255,12 +287,23 @@ export const Provider = React.forwardRef< const shapes = eventShapes.current; const hovered = hoveredShapes.current; const attached = attachedChildrenRef.current; + const captures = capturedShapes.current; + const downs = pointerDowns.current; + const fullscreenStyles = bodyStyles.map(([key]) => document.body.style[key]); return () => { two.renderer.domElement.parentElement?.removeChild( two.renderer.domElement, ); two.pause(); + const fit = two.fit as (() => void) & { attached?: boolean; domElement?: EventTarget }; + if (fit?.attached) { + fit.domElement?.removeEventListener('resize', fit); + fit.attached = false; + } + bodyStyles.forEach(([key, value], index) => { + if (document.body.style[key] === fullscreenStyles[index]) document.body.style[key] = value; + }); two.unbind(); two.release(); const index = Two.Instances.indexOf(two); @@ -270,7 +313,10 @@ export const Provider = React.forwardRef< two.clear(); shapes.clear(); hovered.clear(); - capturedShape.current = null; + captures.clear(); + downs.clear(); + rendererElement.current = null; + hoverTarget.current = null; attached.clear(); childrenOrderRef.current = []; }; @@ -278,6 +324,25 @@ export const Provider = React.forwardRef< // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Constructor options are fixed for this mount; a React key opts into recreation. + useEffect(() => { + if (two || !twoState || !constructorOptions.current) return; + if (!isProduction()) { + for (const key of IMMUTABLE_OPTIONS) { + if (twoProps[key] !== constructorOptions.current[key] && twoProps[key] !== previousOptions.current[key]) { + console.warn(`[react-two.js] Canvas "${key}" is a constructor option. Change the Canvas key to apply it.`); + } + } + } + previousOptions.current = { ...twoProps }; + }, [two, twoState, twoProps]); + + useEffect(() => { + if (two || !twoState) return; + if (props.autostart) twoState.play(); + else twoState.pause(); + }, [two, twoState, props.autostart]); + // Update dimensions from props useEffect(() => { const isRoot = !two; @@ -320,266 +385,142 @@ export const Provider = React.forwardRef< validateChildren(props.children); }, [props.children]); - // Setup event listeners on canvas + // All dispatch paths share coordinates, bubbling, and per-pointer capture. useEffect(() => { if (!twoState) return; - const canvas = twoState.renderer.domElement; - - // Helper to dispatch events with bubbling - const dispatchEvent = ( - shapes: Array, - handlerName: keyof EventHandlers, - nativeEvent: PointerEvent | MouseEvent | WheelEvent, - ) => { - if (shapes.length === 0) return; - - // Get the hit shape (first in array) - const hitShape = shapes[0]; - const point = getCanvasCoordinates(nativeEvent, canvas, twoState!); - - // Get parent hierarchy for bubbling - const hierarchy = getParentHierarchy(hitShape, eventShapes.current); - - // Dispatch to each shape in hierarchy until stopped - for (const currentTarget of hierarchy) { - const entry = eventShapes.current.get(currentTarget); - const handler = entry?.handlers[handlerName]; - - if (handler) { - const event = createTwoEvent( - nativeEvent, - hitShape, - currentTarget, - point, - ); - handler(event); - - if (event.stopped) { - break; - } - } - } + const hits = (event: MouseEvent) => { + const point = getCanvasCoordinates(event, canvas, twoState); + return getShapesAtPoint(eventShapes.current, point.x, point.y, twoState); }; - - // Click handler - const handleClick = (e: MouseEvent) => { - const worldPoint = getWorldCoordinates(e, canvas); - const shapes = getShapesAtPoint( - eventShapes.current, - worldPoint.x, - worldPoint.y, - twoState, - ); - - if (shapes.length > 0) { - dispatchEvent(shapes, 'onClick', e); - } + const release = (id: number) => { + capturedShapes.current.delete(id); + if (canvas.hasPointerCapture?.(id)) canvas.releasePointerCapture(id); }; - - // Context menu handler - const handleContextMenu = (e: MouseEvent) => { - const worldPoint = getWorldCoordinates(e, canvas); - const shapes = getShapesAtPoint( - eventShapes.current, - worldPoint.x, - worldPoint.y, - twoState, - ); - - if (shapes.length > 0) { - dispatchEvent(shapes, 'onContextMenu', e); + const dispatch = ( + target: Shape | Group | undefined, + name: keyof EventHandlers, + native: PointerEvent | MouseEvent | WheelEvent, + recipients?: Array, + ) => { + if (!target) return; + const point = getCanvasCoordinates(native, canvas, twoState); + for (const current of recipients ?? getParentHierarchy(target, eventShapes.current)) { + const handler = eventShapes.current.get(current)?.handlers[name]; + if (!handler) continue; + const event = createTwoEvent(native, target, current, point, { + setPointerCapture: (id) => { + if (!('pointerId' in native) || native.pointerId !== id || !pointerDowns.current.has(id)) return; + canvas.setPointerCapture?.(id); + capturedShapes.current.set(id, current); + const down = pointerDowns.current.get(id); + if (down) down.captured = true; + }, + releasePointerCapture: (id) => { + if (capturedShapes.current.get(id) === current) release(id); + }, + hasPointerCapture: (id) => capturedShapes.current.get(id) === current, + }); + handler(event); + if (event.stopped) break; } }; - - // Double click handler - const handleDoubleClick = (e: MouseEvent) => { - const worldPoint = getWorldCoordinates(e, canvas); - const shapes = getShapesAtPoint( - eventShapes.current, - worldPoint.x, - worldPoint.y, - twoState, - ); - - if (shapes.length > 0) { - dispatchEvent(shapes, 'onDoubleClick', e); + const updateHover = (target: Shape | Group | undefined, event: PointerEvent) => { + const previous = hoverTarget.current; + if (previous === (target ?? null)) return; + const next = new Set(target ? getParentHierarchy(target, eventShapes.current) : []); + const old = hoveredShapes.current; + dispatch(previous ?? undefined, 'onPointerOut', event); + for (const current of old) { + if (!next.has(current)) dispatch(previous ?? undefined, 'onPointerLeave', event, [current]); } - }; - - // Wheel handler - const handleWheel = (e: WheelEvent) => { - const worldPoint = getWorldCoordinates(e, canvas); - const shapes = getShapesAtPoint( - eventShapes.current, - worldPoint.x, - worldPoint.y, - twoState, - ); - - if (shapes.length > 0) { - dispatchEvent(shapes, 'onWheel', e); + dispatch(target, 'onPointerOver', event); + for (const current of next) { + if (!old.has(current)) dispatch(target, 'onPointerEnter', event, [current]); } + hoveredShapes.current = next; + hoverTarget.current = target ?? null; }; - - // Pointer down handler - const handlePointerDown = (e: PointerEvent) => { - const worldPoint = getWorldCoordinates(e, canvas); - const shapes = getShapesAtPoint( - eventShapes.current, - worldPoint.x, - worldPoint.y, - twoState, - ); - - if (shapes.length > 0) { - dispatchEvent(shapes, 'onPointerDown', e); - - // Support pointer capture - if (e.target instanceof Element) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const hasCapture = (e.target as any).hasPointerCapture?.(e.pointerId); - if (hasCapture) { - capturedShape.current = shapes[0]; - } - } - } + const handleClick = (event: MouseEvent) => dispatch(hits(event)[0], 'onClick', event); + const handleDoubleClick = (event: MouseEvent) => dispatch(hits(event)[0], 'onDoubleClick', event); + const handleContextMenu = (event: MouseEvent) => dispatch(hits(event)[0], 'onContextMenu', event); + const handleWheel = (event: WheelEvent) => dispatch(hits(event)[0], 'onWheel', event); + const handlePointerDown = (event: PointerEvent) => { + const target = hits(event)[0]; + pointerDowns.current.set(event.pointerId, { + missed: !target && event.button === 0 && event.isPrimary !== false, + captured: false, + }); + dispatch(target, 'onPointerDown', event); }; - - // Pointer up handler - const handlePointerUp = (e: PointerEvent) => { - // If pointer is captured, send to captured shape - if (capturedShape.current) { - const entry = eventShapes.current.get(capturedShape.current); - if (entry?.handlers.onPointerUp) { - const point = getCanvasCoordinates(e, canvas, twoState!); - const event = createTwoEvent( - e, - capturedShape.current, - capturedShape.current, - point, - ); - entry.handlers.onPointerUp(event); - } - capturedShape.current = null; - return; - } - - const worldPoint = getWorldCoordinates(e, canvas); - const shapes = getShapesAtPoint( - eventShapes.current, - worldPoint.x, - worldPoint.y, - twoState, - ); - - if (shapes.length > 0) { - dispatchEvent(shapes, 'onPointerUp', e); - } else if (props.onPointerMissed) { - props.onPointerMissed(e); - } + const handlePointerMove = (event: PointerEvent) => { + const target = hits(event)[0]; + dispatch(capturedShapes.current.get(event.pointerId) ?? target, 'onPointerMove', event); + updateHover(target, event); }; - - // Pointer move handler - const handlePointerMove = (e: PointerEvent) => { - const worldPoint = getWorldCoordinates(e, canvas); - const shapes = getShapesAtPoint( - eventShapes.current, - worldPoint.x, - worldPoint.y, - twoState, - ); - - // Topmost shape under pointer (front-to-back sorted) - const topShape = shapes.length > 0 ? shapes[0] : null; - - // Build set of currently hovered shapes (topmost shape + its parent hierarchy) - const currentHovered = new Set(); - if (topShape) { - const hierarchy = getParentHierarchy(topShape, eventShapes.current); - for (const s of hierarchy) { - currentHovered.add(s); - } - } - - // Dispatch pointer move to topmost shape hierarchy - if (topShape) { - dispatchEvent([topShape], 'onPointerMove', e); - } - - // Handle pointer enter/leave and over/out - const previousHovered = hoveredShapes.current; - - // Enter / Over: shapes in currentHovered (topmost hierarchy) that weren't hovered before - for (const shape of currentHovered) { - if (!previousHovered.has(shape)) { - dispatchEvent([shape], 'onPointerEnter', e); - dispatchEvent([shape], 'onPointerOver', e); - } + const handlePointerUp = (event: PointerEvent) => { + const target = hits(event)[0]; + const down = pointerDowns.current.get(event.pointerId); + try { + dispatch(capturedShapes.current.get(event.pointerId) ?? target, 'onPointerUp', event); + // A complete primary press begun and ended in empty space is missed. + if (down?.missed && !down.captured && !target) onPointerMissed?.(event); + } finally { + pointerDowns.current.delete(event.pointerId); + release(event.pointerId); } - - // Leave / Out: shapes previously hovered that are no longer in currentHovered - for (const shape of previousHovered) { - if (!currentHovered.has(shape)) { - dispatchEvent([shape], 'onPointerLeave', e); - dispatchEvent([shape], 'onPointerOut', e); - } + }; + const handlePointerCancel = (event: PointerEvent) => { + try { + dispatch(capturedShapes.current.get(event.pointerId) ?? hits(event)[0], 'onPointerCancel', event); + updateHover(undefined, event); + } finally { + pointerDowns.current.delete(event.pointerId); + release(event.pointerId); } - - hoveredShapes.current = currentHovered; }; - - // Pointer cancel handler - const handlePointerCancel = (e: PointerEvent) => { - const worldPoint = getWorldCoordinates(e, canvas); - const shapes = getShapesAtPoint( - eventShapes.current, - worldPoint.x, - worldPoint.y, - twoState, - ); - - if (shapes.length > 0) { - dispatchEvent(shapes, 'onPointerCancel', e); + const handlePointerLeave = (event: PointerEvent) => updateHover(undefined, event); + const handleLostCapture = (event: PointerEvent) => capturedShapes.current.delete(event.pointerId); + const handleOutsideEnd = (event: PointerEvent) => { + if (!(event.target instanceof Node) || !canvas.contains(event.target)) { + pointerDowns.current.delete(event.pointerId); + release(event.pointerId); } - - capturedShape.current = null; }; - - // Attach event listeners canvas.addEventListener('click', handleClick); - canvas.addEventListener('contextmenu', handleContextMenu); canvas.addEventListener('dblclick', handleDoubleClick); + canvas.addEventListener('contextmenu', handleContextMenu); canvas.addEventListener('wheel', handleWheel); canvas.addEventListener('pointerdown', handlePointerDown); - canvas.addEventListener('pointerup', handlePointerUp); canvas.addEventListener('pointermove', handlePointerMove); + canvas.addEventListener('pointerup', handlePointerUp); canvas.addEventListener('pointercancel', handlePointerCancel); - + canvas.addEventListener('pointerleave', handlePointerLeave); + canvas.addEventListener('lostpointercapture', handleLostCapture); + window.addEventListener('pointerup', handleOutsideEnd); + window.addEventListener('pointercancel', handleOutsideEnd); return () => { canvas.removeEventListener('click', handleClick); - canvas.removeEventListener('contextmenu', handleContextMenu); canvas.removeEventListener('dblclick', handleDoubleClick); + canvas.removeEventListener('contextmenu', handleContextMenu); canvas.removeEventListener('wheel', handleWheel); canvas.removeEventListener('pointerdown', handlePointerDown); - canvas.removeEventListener('pointerup', handlePointerUp); canvas.removeEventListener('pointermove', handlePointerMove); + canvas.removeEventListener('pointerup', handlePointerUp); canvas.removeEventListener('pointercancel', handlePointerCancel); + canvas.removeEventListener('pointerleave', handlePointerLeave); + canvas.removeEventListener('lostpointercapture', handleLostCapture); + window.removeEventListener('pointerup', handleOutsideEnd); + window.removeEventListener('pointercancel', handleOutsideEnd); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - twoState, - props.onPointerMissed, - registerEventShape, - unregisterEventShape, - ]); + }, [twoState, onPointerMissed]); // Apply DOM props to Two.js's actual canvas/SVG element useLayoutEffect(() => { if (!twoState) return; // Skip if in fullscreen mode - Two.js manages styles - if (props.fullscreen) { + if (constructorOptions.current?.fullscreen) { const resize = () => { setWidth(twoState.width); setHeight(twoState.height); @@ -608,6 +549,9 @@ export const Provider = React.forwardRef< } else if (key.startsWith('aria-') || key.startsWith('data-')) { // Set as attribute for aria-* and data-* props element.setAttribute(key, String(value)); + } else if (element instanceof SVGElement) { + // SVG properties such as viewBox are readonly animated objects. + element.setAttribute(key, String(value)); } else if (key in element) { // Set as property if it exists on the element const elementWithKey = element as Record; diff --git a/lib/SVG.tsx b/lib/SVG.tsx index 08a489b..8d958a5 100644 --- a/lib/SVG.tsx +++ b/lib/SVG.tsx @@ -1,163 +1,179 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useLayoutEffect, useRef } from 'react'; import { Context, TwoParentContext, TwoSizeContext, useTwo } from './Context'; +import Two from 'two.js'; import type { Group as Instance } from 'two.js/src/group'; +import type { Shape } from 'two.js/src/shape'; import { type EventHandlers } from './Properties'; import type { GroupProps } from './Group'; import { useTwoGroup } from './useTwoObject'; +import { reconcileSceneOrder } from './reconciliation'; export type SVGProps = GroupProps; type ComponentProps = React.PropsWithChildren< - { - [K in Extract]?: Instance[K]; - } & ( - | { - src: string; - content?: never; - } - | { - src?: never; - content: string; - } + { [K in Extract]?: Instance[K] } & ( + | { src: string; content?: never } + | { src?: never; content: string } ) & { - x?: number; - y?: number; - onLoad?: (group: Instance, svg: SVGElement | SVGElement[]) => void; - onError?: (error: Error) => void; - shallow?: boolean; - } & Partial + x?: number; + y?: number; + onLoad?: (group: Instance, svg: SVGElement | SVGElement[]) => void; + onError?: (error: Error) => void; + /** Remove the imported top-level SVG group, retaining nested groups. */ + shallow?: boolean; + } & Partial >; export type RefSVG = Instance; -export const SVG = React.forwardRef( - (props, forwardedRef) => { - const { two } = useTwo(); - const { src, content, onLoad, onError, ...restProps } = props; - - const onLoadRef = useRef(onLoad); - const onErrorRef = useRef(onError); - const rafRef = useRef(null); - const lastLoadedSource = useRef<{ - two: unknown; - key: string | null; - }>({ two: null, key: null }); - - useEffect(() => { - onLoadRef.current = onLoad; - }, [onLoad]); - - useEffect(() => { - onErrorRef.current = onError; - }, [onError]); - - const { - instance: svg, - coreValue, - parentValue, - sizeValue, - renderChildren, - } = useTwoGroup(restProps, forwardedRef, { - specialProps: ['shallow'], - }); - - // Validate props - useEffect(() => { - if (!src && !content) { - console.warn( - '[react-two.js] SVG component requires either "src" or "content" prop' - ); +interface ImportedResource { + unbind: () => unknown; + _renderer?: unknown; +} + +// Record resources before declarative styles can substitute borrowed effects. +function importedResources(root: unknown): Set { + const owned = new Set(); + const visited = new Set(); + const visit = (value: unknown) => { + if (!value || typeof value !== 'object' || visited.has(value)) return; + visited.add(value); + const object = value as Record; + if (typeof object.unbind === 'function') { + owned.add(value as ImportedResource); + } + for (const key of ['children', 'vertices', 'stops']) { + const list = object[key]; + if (Array.isArray(list)) { + visit(list); + list.forEach(visit); } - if (src && content) { - console.warn( - '[react-two.js] SVG component has both "src" and "content" props. Using "src" and ignoring "content"' - ); + } + for (const key of ['fill', 'stroke', 'mask', 'texture', 'translation', 'controls', 'left', 'right']) { + visit(object[key]); + } + }; + visit(root); + return owned; +} + +function releaseImportedResources(two: Two, owned: Set): void { + owned.forEach((resource) => { + // Release the captured graph one resource at a time. Recursing through its + // current children or paints could release borrowed effects or JSX objects. + two.release({ + unbind: () => resource.unbind(), + _renderer: resource._renderer, + } as unknown as Shape); + }); +} + +export const SVG = React.forwardRef((props, forwardedRef) => { + const { two } = useTwo(); + const { src, content, shallow = false, onLoad, onError, ...restProps } = props; + const callbacks = useRef({ onLoad, onError }); + const styleProps = useRef(restProps); + const importedChildren = useRef>([]); + callbacks.current = { onLoad, onError }; + styleProps.current = restProps; + + const { instance: svg, coreValue, parentValue, sizeValue, renderChildren } = + useTwoGroup(restProps, forwardedRef); + + useLayoutEffect(() => { + const imported = importedChildren.current; + if (imported.length > 0) { + const owned = new Set(imported); + reconcileSceneOrder(svg, [...imported, ...Array.from(svg.children).filter((child) => !owned.has(child))]); + } + }); + + useEffect(() => { + if (!two) return; + const controller = new AbortController(); + let active = true; + let imported: Array = []; + let owned = new Set(); + + const reportError = (error: unknown) => { + if (!active) return; + const normalized = error instanceof Error ? error : new Error('Failed to load SVG'); + if (callbacks.current.onError) { + try { callbacks.current.onError(normalized); } + catch (error) { console.error('[react-two.js] Error in SVG onError callback:', error); } + } else console.error('[react-two.js] SVG loading error:', normalized); + }; + + const interpret = (markup: string) => { + if (!active) return; + const document = new DOMParser().parseFromString(markup, 'image/svg+xml'); + if (document.querySelector('parsererror') || document.documentElement.localName !== 'svg') { + throw new Error('Invalid SVG markup: expected an SVG document'); } - }, [src, content]); - - // Load using two.load() - useEffect(() => { - if (!two) return; - - const source = src || content; - if (!source) return; - - const currentKey = source; - const last = lastLoadedSource.current; - - if (last.two === two && last.key === currentKey) { - return; - } - - let mounted = true; - lastLoadedSource.current = { two, key: currentKey }; - - try { - two.load( - source, - (loadedGroup: Instance, svgElement: SVGElement | SVGElement[]) => { - if (!mounted) return; - - svg.add(loadedGroup.children); - - const handleLoad = onLoadRef.current; - if (handleLoad) { - try { - 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); - } - } - } - ); - } catch (err) { - if (!mounted) return; - - const error = - err instanceof Error ? err : new Error('Failed to load SVG'); - - const handleError = onErrorRef.current; - if (handleError) { - try { - handleError(error); - } catch (callbackErr) { - console.error( - '[react-two.js] Error in SVG onError callback:', - callbackErr - ); - } - } else { - console.error('[react-two.js] SVG loading error:', error); + two.load(markup, (loaded: Instance, elements: SVGElement | SVGElement[]) => { + owned = importedResources(loaded); + if (!active) { + releaseImportedResources(two, owned); + return; } - } - - return () => { - mounted = false; - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; + imported = shallow + ? Array.from(loaded.children).flatMap((child) => + child instanceof Two.Group ? Array.from(child.children) : [child]) + : Array.from(loaded.children); + importedChildren.current = imported; + const declaredChildren = Array.from(svg.children); + svg.add(imported); + reconcileSceneOrder(svg, [...imported, ...declaredChildren]); + // Apply cascading styles after attaching the imported artwork. + for (const key of ['fill', 'stroke', 'linewidth', 'cap', 'join', 'miter', 'closed', 'curved', 'automatic', 'beginning', 'ending', 'strokeAttenuation'] as const) { + const value = (styleProps.current as Record)[key]; + if (value !== undefined) (svg as unknown as Record)[key] = value; } - lastLoadedSource.current = { two: null, key: null }; - svg.remove(svg.children); - }; - }, [two, src, content, svg]); - - return ( - - - - {renderChildren()} - - - - ); - } -); + if (!two.playing) two.update(); + try { callbacks.current.onLoad?.(svg, elements); } + catch (error) { console.error('[react-two.js] Error in SVG onLoad callback:', error); } + }); + }; + + if (src && content) { + console.warn('[react-two.js] SVG has both src and content; using src.'); + } + if (src) { + void (async () => { + try { + const response = await fetch(src, { signal: controller.signal }); + if (!response.ok) throw new Error(`Failed to load SVG: HTTP ${response.status}`); + interpret(await response.text()); + } catch (error) { + reportError(error); + } + })(); + } else { + try { + if (!content) throw new Error('SVG requires either src or content'); + interpret(content); + } catch (error) { + reportError(error); + } + } + + return () => { + active = false; + controller.abort(); + svg.remove(imported); + if (importedChildren.current === imported) importedChildren.current = []; + releaseImportedResources(two, owned); + if (!two.playing && two.renderer.domElement.isConnected) two.update(); + }; + }, [two, src, content, shallow, svg]); + + return ( + + + + {renderChildren()} + + + + ); +}); diff --git a/lib/useTwoObject.ts b/lib/useTwoObject.ts index aa448a5..c9c4f5e 100644 --- a/lib/useTwoObject.ts +++ b/lib/useTwoObject.ts @@ -424,20 +424,22 @@ export function useTwoObject< } }); - // Event handler registration and cleanup - useEffect(() => { + // Updating callbacks must not unregister the object: doing so would discard + // hover and pointer capture on ordinary React state updates during a drag. + useLayoutEffect(() => { 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]); + useEffect(() => { + const eventItem = instance as unknown as Shape | Group; + return () => unregisterEventShape(eventItem); + }, [instance, unregisterEventShape]); + // Cleanup on unmount (strict mode safe) useEffect(() => { return () => { diff --git a/tests/browser/index.html b/tests/browser/index.html new file mode 100644 index 0000000..6bd88ee --- /dev/null +++ b/tests/browser/index.html @@ -0,0 +1,17 @@ + + + react-two.js browser regressions + +

Renderer regression checks

+
Running…
+
+

Native pointer capture

+

Select a renderer, then drag the blue circle outside its renderer. Each drag must report capture, bubbled movement, and release.

+ + + +
+
Waiting for a drag.
+ + + diff --git a/tests/browser/regressions.tsx b/tests/browser/regressions.tsx new file mode 100644 index 0000000..7477d5e --- /dev/null +++ b/tests/browser/regressions.tsx @@ -0,0 +1,99 @@ +import { createRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { flushSync } from 'react-dom'; +import Two from 'two.js'; +import { Canvas, Circle, Group, Rectangle, SVG, useTwo, type TwoEvent } from '../../lib/main'; + +const results = document.querySelector('#results')!; +const messages: string[] = []; +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} +const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); +let instance: Two | null = null; +// This standalone test fixture is not a Fast Refresh component module. +// eslint-disable-next-line react-refresh/only-export-components +function Probe() { instance = useTwo().two; return null; } +const root = createRoot(document.querySelector('#test-root')!); +const variants = ['svg', 'SVGRenderer', 'canvas', 'CanvasRenderer', 'webgl', 'WebGLRenderer'] as const; +async function run() { + for (const type of variants) { + const events: TwoEvent[] = []; + const mask = new Two.Circle(0, 0, 20); + const svg = createRef(); + let resolveLoad!: () => void; + let rejectLoad!: (error: Error) => void; + const loaded = new Promise((resolve, reject) => { resolveLoad = resolve; rejectLoad = reject; }); + flushSync(() => root.render( + events.push(event)} onWheel={(event) => events.push(event)}> + + + + + )); + await Promise.race([loaded, new Promise((_, reject) => setTimeout(() => reject(new Error(`${type}: SVG load timeout`)), 2000))]); + await frame(); + const two = instance!; + assert(two, `${type}: mounted`); + const expected = /webgl/i.test(type) ? Two.Types.webgl : /canvas/i.test(type) ? Two.Types.canvas : Two.Types.svg; + assert(two.type === expected, `${type}: normalized type`); + two.update(); + assert(svg.current?.children[0] instanceof Two.Circle, `${type}: shallow SVG import (children=${svg.current?.children.length}, class=${svg.current?.children[0]?.constructor.name})`); + const element = two.renderer.domElement; + const bounds = element.getBoundingClientRect(); + const center = { clientX: Math.round(bounds.left + bounds.width / 4), clientY: Math.round(bounds.top + bounds.height / 4), bubbles: true }; + element.dispatchEvent(new MouseEvent('click', center)); + element.dispatchEvent(new WheelEvent('wheel', center)); + assert(events.length === 2, `${type}: click and wheel bubble through group mask`); + for (const event of events) { + assert(Math.abs(event.point.x - (center.clientX - bounds.left) * 800 / bounds.width) < 0.01 && Math.abs(event.point.y - (center.clientY - bounds.top) * 600 / bounds.height) < 0.01, `${type}: CSS-scaled world point`); + assert(event.target instanceof Two.Rectangle, `${type}: actual leaf target`); + } + element.dispatchEvent(new MouseEvent('click', { ...center, clientX: center.clientX + 25 })); + assert(events.length === 2, `${type}: mask excludes outside geometry`); + messages.push(`PASS ${type}: real renderer, CSS coordinates, masked group events, shallow SVG`); + results.textContent = messages.join('\n'); + } + root.unmount(); + results.textContent = `${messages.join('\n')}\nALL 6 RENDERER VARIANTS PASSED`; +} +void run().catch((error) => { results.textContent = `${messages.join('\n')}\nFAIL ${String(error)}`; }); + +const dragRoot = createRoot(document.querySelector('#drag-root')!); +const dragResults = document.querySelector('#drag-results')!; +function showDrag(type: 'svg' | 'canvas' | 'webgl') { + const log: string[] = []; + const record = (message: string) => { log.push(message); dragResults.textContent = log.join('\n'); }; + let moved = false; + let bubbled = false; + const onDown = (event: TwoEvent) => { + const pointerId = (event.nativeEvent as PointerEvent).pointerId; + event.setPointerCapture(pointerId); + record(`CAPTURE ${type}: ${event.hasPointerCapture(pointerId)}; point ${Math.round(event.point.x)},${Math.round(event.point.y)}`); + }; + const onMove = (event: TwoEvent) => { + if (event.hasPointerCapture((event.nativeEvent as PointerEvent).pointerId) && !moved) { + moved = true; + record(`MOVE ${type}: captured`); + } + }; + const onUp = (event: TwoEvent) => { + const pointerId = (event.nativeEvent as PointerEvent).pointerId; + const point = event.point; + event.releasePointerCapture(pointerId); + record(`RELEASE ${type}: ${!event.hasPointerCapture(pointerId)}; point ${Math.round(point.x)},${Math.round(point.y)}`); + }; + flushSync(() => dragRoot.render( + { + if (moved && !bubbled) { bubbled = true; record(`MOVE ${type}: bubbled`); } + }}> + + + )); + dragResults.textContent = `Ready for ${type}: drag the circle outside the grey renderer.`; +} +for (const type of ['svg', 'canvas', 'webgl'] as const) { + document.querySelector(`#${type}`)!.addEventListener('click', () => showDrag(type)); +} +showDrag('svg'); diff --git a/tests/canvasOptions.test.tsx b/tests/canvasOptions.test.tsx new file mode 100644 index 0000000..34a6622 --- /dev/null +++ b/tests/canvasOptions.test.tsx @@ -0,0 +1,100 @@ +import { createRef } from 'react'; +import { render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import Two from 'two.js'; +import { Canvas, Circle, useTwo } from '../lib/main'; + +let instance: Two | null; +function Probe() { + instance = useTwo().two; + return null; +} +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); + +describe('Canvas constructor options', () => { + it.each(['svg', 'SVGRenderer', 'canvas', 'CanvasRenderer', 'webgl', 'WebGLRenderer'] as const)( + 'normalizes %s to the corresponding renderer constructor', (type) => { + // Real GPU initialization is exercised by tests/browser, not jsdom. + const webgl = vi.spyOn(Two, 'WebGLRenderer').mockImplementation(function(options) { + return new Two.CanvasRenderer(options); + } as unknown as typeof Two.WebGLRenderer); + const { container } = render(); + const expected = /svg/i.test(type) ? Two.Types.svg : /webgl/i.test(type) ? Two.Types.webgl : Two.Types.canvas; + expect(instance?.type).toBe(expected); + expect(container.querySelector(/svg/i.test(type) ? 'svg' : 'canvas')).toBeTruthy(); + expect(webgl).toHaveBeenCalledTimes(/webgl/i.test(type) ? 1 : 0); + }, + ); + + it('updates logical size and play/pause without replacing the scene', () => { + const circle = createRef(); + const view = (width: number, autostart: boolean) => ( + + ); + const { rerender } = render(view(100, false)); + const initial = instance; + const shape = circle.current; + rerender(view(200, true)); + expect(instance).toBe(initial); + expect(circle.current).toBe(shape); + expect(instance?.width).toBe(200); + expect(instance?.playing).toBe(true); + rerender(view(200, false)); + expect(instance?.playing).toBe(false); + }); + + it('warns for immutable changes and applies them on a key remount with cleanup', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const view = (changed: boolean, key = 'original') => ( + + + + ); + const { rerender, container } = render(view(false)); + const old = instance!; + const oldElement = old.renderer.domElement; + rerender(view(true)); + expect(instance).toBe(old); + for (const key of ['type', 'ratio', 'overdraw', 'smoothing', 'fullscreen']) { + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`"${key}"`)); + } + expect(container.querySelector('svg')).toBe(oldElement); + // Remount renderer options without enabling fullscreen for this unit test. + rerender(); + expect(instance).not.toBe(old); + expect(Two.Instances).not.toContain(old); + expect(oldElement.isConnected).toBe(false); + expect(old.scene.children).toHaveLength(0); + expect(instance?.scene.children).toHaveLength(1); + }); + + it('removes fullscreen resize listeners and restores body styles on a key remount', () => { + const remove = vi.spyOn(window, 'removeEventListener'); + const previousPosition = document.body.style.position; + const { rerender } = render(); + const oldFit = instance!.fit; + expect(document.body.style.position).toBe('fixed'); + document.body.style.backgroundColor = 'red'; + rerender(); + expect(remove).toHaveBeenCalledWith('resize', oldFit); + expect(document.body.style.position).toBe(previousPosition); + expect(document.body.style.backgroundColor).toBe('red'); + document.body.style.backgroundColor = ''; + }); + + it('treats equivalent aliases as unchanged', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { rerender } = render(); + rerender(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('suppresses constructor-change warnings in production', () => { + vi.stubEnv('NODE_ENV', 'production'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { rerender } = render(); + rerender(); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/eventSemantics.test.tsx b/tests/eventSemantics.test.tsx new file mode 100644 index 0000000..8c95e56 --- /dev/null +++ b/tests/eventSemantics.test.tsx @@ -0,0 +1,292 @@ +import { createRef, memo } from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import Two from 'two.js'; +import { Canvas, Circle, Group, Points, Rectangle, Text, type TwoEvent } from '../lib/main'; + +const rect = { left: 10, top: 20, right: 410, bottom: 320, width: 400, height: 300, x: 10, y: 20, toJSON: () => ({}) }; +function renderer(container: HTMLElement) { + const element = container.querySelector('svg,canvas')!; + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(rect); + return element; +} +const inside = { clientX: 110, clientY: 95, pointerId: 1, button: 0 }; +const outside = { clientX: 390, clientY: 290, pointerId: 1, button: 0 }; +afterEach(() => vi.restoreAllMocks()); + +// CSS renders 800x600 logical pixels in a 400x300 rectangle with a nonzero offset. +describe.each(['SVGRenderer', 'CanvasRenderer'] as const)('%s event semantics', (type) => { + it('reports identical world coordinates and bubbles through noninteractive intermediate parents', () => { + const shape = createRef(); + const group = createRef(); + const onShape = vi.fn(); + const onGroup = vi.fn(); + const { container } = render( + + + + ); + const element = renderer(container); + fireEvent.click(element, inside); + fireEvent.pointerDown(element, inside); + fireEvent.wheel(element, { ...inside, deltaY: 5 }); + expect(onShape).toHaveBeenCalledTimes(3); + expect(onGroup).toHaveBeenCalledTimes(3); + for (const [event] of onShape.mock.calls) { + expect(event.point).toEqual({ x: 200, y: 150 }); + expect(event.target).toBe(shape.current); + expect(event.currentTarget).toBe(shape.current); + } + for (const [event] of onGroup.mock.calls) { + expect(event.point).toEqual({ x: 200, y: 150 }); + expect(event.target).toBe(shape.current); + expect(event.currentTarget).toBe(group.current); + } + }); + + it('uses current nested scene order across memoized parent reorders', () => { + const left = vi.fn(); + const right = vi.fn(); + const Layer = memo(({ onClick }: { onClick: () => void }) => ( + + )); + const view = (reverse: boolean) => + {reverse ? [, ] + : [, ]} + ; + const { container, rerender } = render(view(false)); + const element = renderer(container); + fireEvent.click(element, inside); + expect(right).toHaveBeenCalledTimes(1); + expect(left).not.toHaveBeenCalled(); + rerender(view(true)); + fireEvent.click(element, inside); + expect(left).toHaveBeenCalledTimes(1); + expect(right).toHaveBeenCalledTimes(1); + }); + + it('targets actual child geometry for sparse groups and ignores transparent, invisible, and clipped content', () => { + const onClick = vi.fn(); + const leaf = createRef(); + const { container } = render( + + + + + + + + + ); + const element = renderer(container); + fireEvent.click(element, inside); // Empty space in the group's aggregate bounds. + expect(onClick).not.toHaveBeenCalled(); + fireEvent.click(element, { clientX: 60, clientY: 95 }); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].target).toBe(leaf.current); + }); + + it('clips hit geometry to transformed owner masks and excludes the mask itself', () => { + const onClick = vi.fn(); + const mask = new Two.Circle(0, 0, 20); + const { container } = render( + + + + ); + const element = renderer(container); + fireEvent.click(element, inside); + expect(onClick).toHaveBeenCalledTimes(1); + fireEvent.click(element, { clientX: 135, clientY: 95 }); // Inside rectangle, outside mask. + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it.each(['text', 'points'] as const)('ignores unpainted %s above an interactive shape while retaining painted hits', (kind) => { + const points = createRef(); + const behind = vi.fn(); + const front = vi.fn(); + const view = (fill: string, stroke = 'none', linewidth = 1) => + + + {kind === 'text' + ? + : } + + ; + const { container, rerender } = render(view('transparent')); + if (kind === 'points') { + // Two.js 0.8.24's Points bounds call Matrix.multiply with two arguments, + // which throws. Supply bounds to isolate paint filtering from that bug. + vi.spyOn(points.current!, 'getBoundingClientRect').mockReturnValue({ + left: 180, right: 220, top: 130, bottom: 170, width: 40, height: 40, + }); + } + const element = renderer(container); + fireEvent.click(element, inside); + rerender(view('none', 'red', 0)); + fireEvent.click(element, inside); + expect(behind).toHaveBeenCalledTimes(2); + expect(front).not.toHaveBeenCalled(); + rerender(view('none', 'red', 2)); + fireEvent.click(element, inside); + rerender(view('red')); + fireEvent.click(element, inside); + expect(front).toHaveBeenCalledTimes(2); + expect(behind).toHaveBeenCalledTimes(2); + }); + + it('stops bubbling and emits each hover transition once with the real leaf target', () => { + const onParentClick = vi.fn(); + const onEnter = vi.fn(); + const onOver = vi.fn(); + const onLeave = vi.fn(); + const shape = createRef(); + const { container } = render( + + event.stopPropagation()} onPointerOver={() => {}} /> + + ); + const element = renderer(container); + fireEvent.click(element, inside); + expect(onParentClick).not.toHaveBeenCalled(); + fireEvent.pointerMove(element, inside); + fireEvent.pointerMove(element, inside); + expect(onEnter).toHaveBeenCalledTimes(1); + expect(onOver).toHaveBeenCalledTimes(1); + expect(onEnter.mock.calls[0][0].target).toBe(shape.current); + fireEvent.pointerMove(element, outside); + expect(onLeave).toHaveBeenCalledTimes(1); + expect(onLeave.mock.calls[0][0].target).toBe(shape.current); + }); + + it('fires missed only for complete empty primary presses, excluding hits, orphan ups, clicks, and cancels', () => { + const onMissed = vi.fn(); + const { container } = render( + {}} /> + ); + const element = renderer(container); + fireEvent.pointerUp(element, outside); + fireEvent.pointerDown(element, outside); + fireEvent.pointerUp(window, outside); // A release outside ends the press. + fireEvent.pointerUp(element, outside); + fireEvent.click(element, outside); + fireEvent.pointerDown(element, inside); + fireEvent.pointerUp(element, outside); + fireEvent.pointerDown(element, outside); + fireEvent.pointerUp(element, inside); + fireEvent.pointerDown(element, outside); + fireEvent.pointerCancel(element, outside); + fireEvent.pointerUp(element, outside); + fireEvent.pointerDown(element, { ...outside, button: 2 }); + fireEvent.pointerUp(element, { ...outside, button: 2 }); + expect(onMissed).not.toHaveBeenCalled(); + fireEvent.pointerDown(element, outside); + fireEvent.pointerUp(element, outside); + fireEvent.click(element, outside); + expect(onMissed).toHaveBeenCalledTimes(1); + expect(onMissed.mock.calls[0][0].type).toBe('pointerup'); + }); + + it('captures moves, ups, and cancels per pointer with bubbling and consistent payloads', () => { + const moves = vi.fn(); + const ups = vi.fn(); + const cancels = vi.fn(); + const parentMoves = vi.fn(); + const missed = vi.fn(); + const captured: TwoEvent[] = []; + const onDown = (event: TwoEvent) => { + const id = (event.nativeEvent as PointerEvent).pointerId; + event.setPointerCapture(id); + expect(event.hasPointerCapture(id)).toBe(true); + captured.push(event); + }; + const { container } = render( + + ); + const element = renderer(container); + fireEvent.pointerDown(element, inside); + fireEvent.pointerDown(element, { ...inside, pointerId: 2 }); + fireEvent.pointerMove(element, { ...outside, pointerId: 3 }); + expect(moves).not.toHaveBeenCalled(); + fireEvent.pointerMove(element, outside); + expect(moves).toHaveBeenCalledTimes(1); + expect(parentMoves).toHaveBeenCalledTimes(1); + expect(moves.mock.calls[0][0].point).toEqual({ x: 760, y: 540 }); + fireEvent.pointerUp(element, outside); + expect(ups).toHaveBeenCalledTimes(1); + expect(captured[0].hasPointerCapture(1)).toBe(false); + expect(captured[1].hasPointerCapture(2)).toBe(true); + fireEvent.pointerCancel(element, { ...outside, pointerId: 2 }); + expect(cancels).toHaveBeenCalledTimes(1); + expect(captured[1].hasPointerCapture(2)).toBe(false); + fireEvent.pointerMove(element, outside); + expect(moves).toHaveBeenCalledTimes(1); + expect(missed).not.toHaveBeenCalled(); + }); + + it('retains capture through callback rerenders and releases it when the last handler is removed', () => { + const first = vi.fn(); + const latest = vi.fn(); + const down = (event: TwoEvent) => event.setPointerCapture(1); + const view = (move: typeof first, enabled = true) => + + ; + const { container, rerender } = render(view(first)); + const element = renderer(container); + const setCapture = vi.fn(); + const releaseCapture = vi.fn(); + Object.defineProperties(element, { + setPointerCapture: { value: setCapture }, + hasPointerCapture: { value: () => true }, + releasePointerCapture: { value: releaseCapture }, + }); + fireEvent.pointerDown(element, inside); + rerender(view(latest)); + fireEvent.pointerMove(element, outside); + expect(first).not.toHaveBeenCalled(); + expect(latest).toHaveBeenCalledTimes(1); + expect(releaseCapture).not.toHaveBeenCalled(); + rerender(view(latest, false)); + expect(releaseCapture).toHaveBeenCalledWith(1); + fireEvent.pointerMove(element, outside); + expect(latest).toHaveBeenCalledTimes(1); + }); + + it('supports explicit capture release and lost capture without routing later moves', () => { + const moves = vi.fn(); + let down!: TwoEvent; + const { container } = render( + { down = event; event.setPointerCapture(1); }} onPointerMove={moves} /> + ); + const element = renderer(container); + fireEvent.pointerDown(element, inside); + down.releasePointerCapture(1); + fireEvent.pointerMove(element, outside); + expect(moves).not.toHaveBeenCalled(); + fireEvent.pointerDown(element, inside); + fireEvent(element, new PointerEvent('lostpointercapture', outside)); + expect(down.hasPointerCapture(1)).toBe(false); + fireEvent.pointerMove(element, outside); + expect(moves).not.toHaveBeenCalled(); + }); +}); + +describe('SVG viewport coordinates', () => { + it('uses the SVG screen transform rather than assuming CSS viewport size scales artwork', () => { + const onClick = vi.fn(); + const { container } = render( + + ); + const element = renderer(container); + expect(element.getAttribute('viewBox')).toBe('0 0 800 600'); + // An unscaled viewport, or aspect-ratio letterboxing, has a different CTM + // from the dimensions ratio. jsdom needs that native matrix supplied. + Object.defineProperty(element, 'getScreenCTM', { value: () => ({ + inverse: () => ({ a: 1, b: 0, c: 0, d: 1, e: -10, f: -20 }), + }) }); + fireEvent.click(element, { clientX: 210, clientY: 170 }); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][0].point).toEqual({ x: 200, y: 150 }); + }); +}); diff --git a/tests/events.test.tsx b/tests/events.test.tsx index 500b6f8..20b8976 100644 --- a/tests/events.test.tsx +++ b/tests/events.test.tsx @@ -37,33 +37,12 @@ beforeEach(() => { describe('react-two.js Event System', () => { describe('hitTest Utility', () => { - it('should correctly hit-test shape/group using getBoundingClientRect and stage offsets', () => { - const mockTwo = { - width: 800, - height: 600, - } as Two; - - // Create a mock group shape returning bounds [-50, -25, 50, 25] relative to center - const mockGroup = { - getBoundingClientRect: vi.fn().mockReturnValue({ - left: -50, - right: 50, - top: -25, - bottom: 25, - width: 100, - height: 50, - }), - }; - - // Stage center offset: (400, 300) - // Top-Left DOM bounds: X in [350, 450], Y in [275, 325] - - // Point inside bounds - expect(hitTest(mockGroup as unknown as Two.Group, 400, 300, mockTwo)).toBe(true); - - // Point outside bounds - expect(hitTest(mockGroup as unknown as Two.Group, 200, 300, mockTwo)).toBe(false); - expect(hitTest(mockGroup as unknown as Two.Group, 400, 500, mockTwo)).toBe(false); + it('uses world bounds without an artificial stage-center offset', () => { + const shape = { + getBoundingClientRect: () => ({ left: -50, right: 50, top: -25, bottom: 25 }), + } as unknown as Two.Group; + expect(hitTest(shape, 0, 0)).toBe(true); + expect(hitTest(shape, 400, 300)).toBe(false); }); it('should fallback to checking children if shape has no getBoundingClientRect or contains', () => { @@ -88,21 +67,14 @@ describe('react-two.js Event System', () => { expect(hitTest(mockShape as unknown as Two.Shape, 10, 10)).toBe(false); }); - it('should sort hits front-to-back (frontmost shape first)', () => { - const mockBackgroundShape = { id: 'bg' }; - const mockForegroundShape = { id: 'fg' }; - - const shapesMap = new Map(); - shapesMap.set(mockBackgroundShape, { shape: mockBackgroundShape, handlers: {} }); - shapesMap.set(mockForegroundShape, { shape: mockForegroundShape, handlers: {} }); - - const hits = [mockBackgroundShape, mockForegroundShape]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sorted = sortFrontToBack(hits as any, shapesMap); - - // Foreground shape registered later must be FIRST in the hits array - expect(sorted[0]).toBe(mockForegroundShape); - expect(sorted[1]).toBe(mockBackgroundShape); + it('keeps unrelated hits stable instead of inventing registration draw order', () => { + const back = new Two.Circle(0, 0, 10); + const front = new Two.Circle(0, 0, 10); + const registry = new Map([ + [back, { shape: back, handlers: {} }], + [front, { shape: front, handlers: {} }], + ]); + expect(sortFrontToBack([back, front], registry)).toEqual([back, front]); }); it('should sort hits based on parent.children index when sharing same parent group', () => { @@ -142,7 +114,7 @@ describe('react-two.js Event System', () => { onPointerOut={onPointerOut} onPointerLeave={onPointerLeave} > - + ); @@ -176,7 +148,9 @@ describe('react-two.js Event System', () => { }); // Verify pointer events dispatched - expect(canvasElement).toBeDefined(); + expect(onPointerOver).toHaveBeenCalledTimes(1); + expect(onPointerOut).toHaveBeenCalledTimes(1); + expect(onPointerLeave).toHaveBeenCalledTimes(1); }); it('should trigger over on top shape and out on bottom shape when moving onto overlapping top shape', () => { diff --git a/tests/svgLifecycle.test.tsx b/tests/svgLifecycle.test.tsx new file mode 100644 index 0000000..680344a --- /dev/null +++ b/tests/svgLifecycle.test.tsx @@ -0,0 +1,258 @@ +import { createRef, StrictMode } from 'react'; +import { act, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import Two from 'two.js'; +import { Canvas, Circle, SVG, useTwo } from '../lib/main'; + +const artwork = ''; +let instance: Two | null; +function Probe() { instance = useTwo().two; return null; } +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const response = (markup = artwork) => ({ ok: true, text: async () => markup }) as Response; + +// Tests retain real Two.js interpretation and control only the network boundary. +describe('SVG loading lifecycle', () => { + it('supports shallow imports, cascading styles, transforms, and source replacement without removing JSX children', () => { + const svg = createRef(); + const child = createRef(); + const onLoad = vi.fn(); + const view = (shallow: boolean, content = artwork) => ( + + + + ); + const { rerender } = render(view(false)); + const wrapper = svg.current!; + const jsx = child.current; + expect(wrapper.children).toHaveLength(2); + expect(wrapper.children[0]).toBeInstanceOf(Two.Group); + expect((wrapper.children[0] as Two.Group).children[0].id).toBe('nested'); + expect(wrapper.translation.x).toBe(30); + expect(wrapper.scale).toBe(2); + expect(wrapper.fill).toBe('blue'); + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onLoad.mock.calls[0][0]).toBe(wrapper); + const imported = wrapper.children[0]; + const unbind = vi.spyOn(imported, 'unbind'); + rerender(view(true)); + expect(wrapper.children[0].id).toBe('nested'); + expect(wrapper.children[1]).toBe(jsx); + expect(unbind).toHaveBeenCalled(); + expect(onLoad).toHaveBeenCalledTimes(2); + rerender(view(true, '')); + expect(wrapper.children[0]).toBeInstanceOf(Two.Rectangle); + expect(wrapper.children[1]).toBe(jsx); + expect(wrapper.children).toHaveLength(2); + }); + + it('aborts replacement requests and ignores a stale response even when the network ignores cancellation', async () => { + const first = deferred(); + const second = deferred(); + const fetchMock = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + vi.stubGlobal('fetch', fetchMock); + const svg = createRef(); + const onLoad = vi.fn(); + const onError = vi.fn(); + const view = (src: string) => ; + const { rerender, unmount } = render(view('/first.svg?revision=1')); + const firstSignal = fetchMock.mock.calls[0][1].signal; + rerender(view('/second.svg')); + expect(firstSignal.aborted).toBe(true); + await act(async () => { second.resolve(response()); }); + const winner = svg.current!.children[0]; + await act(async () => { first.resolve(response('')); }); + expect(svg.current!.children).toEqual([winner]); + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + const unbind = vi.spyOn(winner, 'unbind'); + unmount(); + expect(unbind).toHaveBeenCalled(); + expect(fetchMock.mock.calls[1][1].signal.aborted).toBe(true); + }); + + it('ignores pending URL results and errors after unmount', async () => { + const pending = deferred(); + vi.stubGlobal('fetch', vi.fn(() => pending.promise)); + const onLoad = vi.fn(); + const onError = vi.fn(); + const { unmount } = render(); + unmount(); + await act(async () => { pending.reject(new Error('aborted')); }); + expect(onLoad).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it.each(['', '
not svg
', ''])('reports invalid inline content exactly once: %s', (content) => { + const onLoad = vi.fn(); + const onError = vi.fn(); + render(); + expect(onLoad).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); + }); + + it.each(['http', 'network', 'markup'])('reports %s URL failures exactly once', async (kind) => { + vi.stubGlobal('fetch', vi.fn(async () => { + if (kind === 'network') throw new Error('network unavailable'); + return kind === 'http' ? { ok: false, status: 404 } : response(''); + })); + const onLoad = vi.fn(); + const onError = vi.fn(); + await act(async () => { render(); }); + expect(onLoad).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('uses latest callbacks without reloading and preserves JSX sibling ordering', () => { + const svg = createRef(); + const before = createRef(); + const after = createRef(); + const load = vi.spyOn(Two.prototype, 'load'); + const view = (onLoad: () => void) => + + + + ; + const { rerender } = render(view(vi.fn())); + rerender(view(vi.fn())); + expect(load).toHaveBeenCalledTimes(1); + expect(instance!.scene.children).toEqual([before.current, svg.current, after.current]); + }); + + it('retains one import after StrictMode effect replay', () => { + const ref = createRef(); + render(); + expect(ref.current!.children).toHaveLength(1); + }); +}); + +describe('SVG ownership and callbacks', () => { + it.each([false, true])('releases imported SVG DOM nodes on replacement and removal (shallow=%s)', (shallow) => { + const svg = createRef(); + const child = createRef(); + const gradient = new Two.LinearGradient(0, 0, 100, 0, [new Two.Stop(0, 'red'), new Two.Stop(1, 'blue')]); + const borrowedUnbind = vi.spyOn(gradient, 'unbind'); + const view = (content: string, enabled = true) => + {enabled && + + } + + ; + const { rerender } = render(view(artwork)); + const wrapper = svg.current!; + const jsx = child.current!; + const rendererStates = () => { + const states: Array<{ elem?: Element }> = []; + const visit = (shape: Two.Shape) => { + states.push((shape as unknown as { _renderer: { elem?: Element } })._renderer); + if (shape instanceof Two.Group) shape.children.forEach(visit); + }; + visit(wrapper.children[0]); + return states; + }; + const previous = rendererStates(); + expect(previous.every((state) => state.elem?.isConnected)).toBe(true); + rerender(view('')); + expect(previous.every((state) => state.elem === undefined)).toBe(true); + expect(wrapper.children[1]).toBe(jsx); + expect(borrowedUnbind).not.toHaveBeenCalledWith(); + const current = rendererStates(); + rerender(view(artwork, false)); + expect(current.every((state) => state.elem === undefined)).toBe(true); + expect(borrowedUnbind).not.toHaveBeenCalledWith(); + }); + + it('releases WebGL textures and buffers without unbinding borrowed effects', () => { + const svg = createRef(); + const borrowed = new Two.LinearGradient(0, 0, 100, 0, [new Two.Stop(0, 'red')]); + const borrowedUnbind = vi.spyOn(borrowed, 'unbind'); + const view = (content: string, enabled = true) => + {enabled && } + + ; + const { rerender } = render(view(artwork)); + const gl = { deleteTexture: vi.fn(), deleteBuffer: vi.fn() }; + // Exercise Two.js's real release path with deterministic GPU handles. + const two = instance!; + const originalType = two.type; + Object.assign(two, { type: Two.Types.webgl }); + Object.assign(two.renderer, { ctx: gl }); + const track = () => { + const group = svg.current!.children[0] as Two.Group; + const nested = group.children[0] as Two.Group; + const shape = nested.children[0]; + const state = (shape as unknown as { _renderer: Record })._renderer; + const handles = { texture: {}, positionBuffer: {}, effect: {} }; + Object.assign(state, handles); + return { state, handles }; + }; + const first = track(); + rerender(view(artwork.replace('red', 'blue'))); + expect(gl.deleteTexture).toHaveBeenCalledWith(first.handles.texture); + expect(gl.deleteBuffer).toHaveBeenCalledWith(first.handles.positionBuffer); + expect(first.state.texture).toBeUndefined(); + expect(first.state.positionBuffer).toBeUndefined(); + expect(first.state.effect).toBeNull(); + const second = track(); + rerender(view(artwork, false)); + expect(gl.deleteTexture).toHaveBeenCalledWith(second.handles.texture); + expect(gl.deleteBuffer).toHaveBeenCalledWith(second.handles.positionBuffer); + expect(gl.deleteTexture).toHaveBeenCalledTimes(2); + expect(gl.deleteBuffer).toHaveBeenCalledTimes(2); + expect(borrowedUnbind).not.toHaveBeenCalled(); + Object.assign(two, { type: originalType }); + delete (two.renderer as unknown as { ctx?: unknown }).ctx; + }); + + it('does not release borrowed declarative gradients when imported content is replaced', () => { + const gradient = new Two.LinearGradient(0, 0, 100, 0, [new Two.Stop(0, 'red'), new Two.Stop(1, 'blue')]); + const unbind = vi.spyOn(gradient, 'unbind'); + const view = (content: string) => ; + const { rerender } = render(view(artwork)); + rerender(view('')); + expect(unbind).not.toHaveBeenCalled(); + }); + + it('uses the latest callback for pending URL loads without restarting the request', async () => { + const pending = deferred(); + const fetchMock = vi.fn(() => pending.promise); + vi.stubGlobal('fetch', fetchMock); + const first = vi.fn(); + const last = vi.fn(); + const { rerender } = render(); + rerender(); + await act(async () => pending.resolve(response())); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + expect(last).toHaveBeenCalledTimes(1); + }); + + it('does not classify user onLoad exceptions as loading errors', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const onError = vi.fn(); + render( { throw new Error('callback failure'); }} onError={onError} />); + expect(onError).not.toHaveBeenCalled(); + }); +}); + +it('keeps imports before JSX children when the JSX children are reordered', () => { + const svg = createRef(); + const first = createRef(); + const last = createRef(); + const view = (reverse: boolean) => + {reverse ? [, ] + : [, ]} + ; + const { rerender } = render(view(false)); + const imported = svg.current!.children[0]; + expect(svg.current!.children).toEqual([imported, first.current, last.current]); + rerender(view(true)); + expect(svg.current!.children).toEqual([imported, last.current, first.current]); +});