From 873ec0c68bdc59c8fdcee74d55f2ee7efc8fc334 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Thu, 29 Jan 2026 17:18:46 +0700 Subject: [PATCH 1/5] feat(joint-react): refactor scheduler to improve batching and add new interface --- packages/joint-react/src/store/graph-store.ts | 2 +- .../joint-react/src/utils/scheduler-old.ts | 57 ++++++++ packages/joint-react/src/utils/scheduler.ts | 125 ++++++++++++------ 3 files changed, 140 insertions(+), 44 deletions(-) create mode 100644 packages/joint-react/src/utils/scheduler-old.ts diff --git a/packages/joint-react/src/store/graph-store.ts b/packages/joint-react/src/store/graph-store.ts index f95607442..86201525b 100644 --- a/packages/joint-react/src/store/graph-store.ts +++ b/packages/joint-react/src/store/graph-store.ts @@ -24,7 +24,7 @@ import { createDefaultLinkMapper, } from '../state/graph-state-selectors'; import type { OnChangeOptions } from '../utils/cell/listen-to-cell-change'; -import { createScheduler } from '../utils/scheduler'; +import { createScheduler } from '../utils/scheduler-old'; export const DEFAULT_CELL_NAMESPACE: Record = { ...shapes, diff --git a/packages/joint-react/src/utils/scheduler-old.ts b/packages/joint-react/src/utils/scheduler-old.ts new file mode 100644 index 000000000..5ec990378 --- /dev/null +++ b/packages/joint-react/src/utils/scheduler-old.ts @@ -0,0 +1,57 @@ +// eslint-disable-next-line camelcase +import { unstable_getCurrentPriorityLevel, unstable_scheduleCallback } from 'scheduler'; +/** + * Creates a debounced function that uses React's internal scheduler for timing. + * Multiple calls within the same synchronous execution cycle are batched into a single + * execution in the next available event loop tick. All callbacks stored by id will be + * executed when the scheduled work flushes. + * @param userCallback The function to be debounced and scheduled. + * @param priorityLevel The priority level to run the task at. + * @returns A function you call to schedule your work with an optional callback and id. + */ +export function createScheduler(userCallback: () => void, priorityLevel?: number) { + let callbackId: unknown | null = null; + const callbacks = new Map void>(); + + const effectivePriority = + priorityLevel === undefined ? unstable_getCurrentPriorityLevel() : priorityLevel; + + /** + * The actual function that processes the batched callbacks. + * This runs asynchronously via the scheduler. + */ + const flushScheduledWork = () => { + callbackId = null; + + // Collect all ids before clearing + + // Execute all stored callbacks with their respective ids + for (const [id, callback] of callbacks) { + callback(id); + } + + // Execute the main callback for each id after stored callbacks + userCallback(); + + // Clear all stored callbacks after execution + callbacks.clear(); + }; + + /** + * This is the function the user calls to schedule a task. + * It stores the callback with its id and ensures the flush function is scheduled once. + * @param id Optional id string to pass to the callback. + * @param callback Optional callback function that receives an id parameter. + */ + return (id?: string, callback?: (id: string) => void) => { + if (id !== undefined && callback !== undefined) { + // Store callback in map with id as key + callbacks.set(id, callback); + } + + if (callbackId === null) { + // Schedule the flush function if it hasn't been scheduled already + callbackId = unstable_scheduleCallback(effectivePriority, flushScheduledWork); + } + }; +} diff --git a/packages/joint-react/src/utils/scheduler.ts b/packages/joint-react/src/utils/scheduler.ts index 5ec990378..5156ff45d 100644 --- a/packages/joint-react/src/utils/scheduler.ts +++ b/packages/joint-react/src/utils/scheduler.ts @@ -1,57 +1,96 @@ -// eslint-disable-next-line camelcase -import { unstable_getCurrentPriorityLevel, unstable_scheduleCallback } from 'scheduler'; +import { + unstable_getCurrentPriorityLevel as getCurrentPriorityLevel, + unstable_scheduleCallback as scheduleCallback, +} from "scheduler"; + /** - * Creates a debounced function that uses React's internal scheduler for timing. - * Multiple calls within the same synchronous execution cycle are batched into a single - * execution in the next available event loop tick. All callbacks stored by id will be - * executed when the scheduled work flushes. - * @param userCallback The function to be debounced and scheduled. - * @param priorityLevel The priority level to run the task at. - * @returns A function you call to schedule your work with an optional callback and id. + * Options for creating a graph updates scheduler. */ -export function createScheduler(userCallback: () => void, priorityLevel?: number) { - let callbackId: unknown | null = null; - const callbacks = new Map void>(); - - const effectivePriority = - priorityLevel === undefined ? unstable_getCurrentPriorityLevel() : priorityLevel; - +export interface GraphUpdatesSchedulerOptions { /** - * The actual function that processes the batched callbacks. - * This runs asynchronously via the scheduler. + * Callback invoked when scheduled updates are flushed. + * Receives all accumulated data since the last flush. */ - const flushScheduledWork = () => { - callbackId = null; + readonly onFlush: (data: Data) => void; - // Collect all ids before clearing - - // Execute all stored callbacks with their respective ids - for (const [id, callback] of callbacks) { - callback(id); - } + /** + * Optional React scheduler priority level. + * If not specified, uses the current priority level. + */ + readonly priorityLevel?: number; +} - // Execute the main callback for each id after stored callbacks - userCallback(); +/** + * Scheduler that batches data updates and flushes them together. + * Uses React's internal scheduler for timing, batching multiple calls + * within the same synchronous execution cycle into a single flush. + * Data starts as empty object `{}` and resets after each flush. + * @example + * ```ts + * interface MyData { + * elements?: string[]; + * links?: string[]; + * } + * + * const scheduler = new GraphUpdatesScheduler({ + * onFlush: (data) => { + * console.log(data.elements, data.links); + * }, + * }); + * + * // Multiple calls are batched + * scheduler.scheduleData((prev) => ({ ...prev, elements: ['el1'] })); + * scheduler.scheduleData((prev) => ({ ...prev, links: ['link1'] })); + * // onFlush called once with combined result: { elements: ['el1'], links: ['link1'] } + * ``` + */ +export class Scheduler { + private readonly onFlush: (data: Data) => void; + private readonly effectivePriority: number; + private callbackId: unknown | null = null; + private currentData: Data = {} as Data; - // Clear all stored callbacks after execution - callbacks.clear(); - }; + constructor(options: GraphUpdatesSchedulerOptions) { + const { onFlush, priorityLevel } = options; + this.onFlush = onFlush; + this.effectivePriority = + priorityLevel === undefined ? getCurrentPriorityLevel() : priorityLevel; + } /** - * This is the function the user calls to schedule a task. - * It stores the callback with its id and ensures the flush function is scheduled once. - * @param id Optional id string to pass to the callback. - * @param callback Optional callback function that receives an id parameter. + * Schedule a data update using an updater function. + * Multiple calls are batched and flushed together. + * @param updater Function that receives previous data and returns updated data. */ - return (id?: string, callback?: (id: string) => void) => { - if (id !== undefined && callback !== undefined) { - // Store callback in map with id as key - callbacks.set(id, callback); - } + scheduleData = (updater: (previousData: Data) => Data): void => { + this.currentData = updater(this.currentData); - if (callbackId === null) { - // Schedule the flush function if it hasn't been scheduled already - callbackId = unstable_scheduleCallback(effectivePriority, flushScheduledWork); + if (this.callbackId === null) { + this.callbackId = scheduleCallback( + this.effectivePriority, + this.flushScheduledWork, + ); } }; + + private flushScheduledWork = (): void => { + this.callbackId = null; + + const dataToFlush = this.currentData; + this.currentData = {} as Data; + + this.onFlush(dataToFlush); + }; +} + +/** + * Creates a simple scheduler function that batches multiple calls into a single flush. + * @param callback The callback to invoke on flush + * @returns A function to schedule updates + */ +export function createScheduler(callback: () => void): () => void { + const scheduler = new Scheduler>({ + onFlush: callback, + }); + return () => scheduler.scheduleData((previous) => previous); } From 5696642f1f1b7c1b496281969221ef48c47ad200 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 30 Jan 2026 02:40:09 +0700 Subject: [PATCH 2/5] chore(joint-react): implement batch caching and state flushing for JointJS elements and links - Add BatchCache class for managing batched updates for links, ports, and clear views. - Introduce ClearViewCacheEntry and related functions for managing clear view updates. - Create PortUpdateCacheEntry and functions for merging port updates and groups. - Implement state flushing functions to handle element and link updates in a React-friendly manner. - Develop example story demonstrating cell actions with dynamic node and link management. - Define GraphSchedulerData type for unified scheduling of updates across elements, links, and ports. --- .../decorators/with-simple-data.tsx | 26 +- packages/joint-react/README.md | 48 +- packages/joint-react/jest.config.js | 17 +- packages/joint-react/package.json | 3 +- .../graph/graph-provider.stories.tsx | 20 +- .../src/components/graph/graph-provider.tsx | 41 +- .../__tests__/highlighter-cleanup.test.tsx | 6 +- .../highlighters/__tests__/stroke.test.tsx | 6 +- .../link/__tests__/base-link.test.tsx | 224 --- .../link/__tests__/link-label.test.tsx | 511 ----- .../src/components/link/base-link.stories.tsx | 326 ---- .../src/components/link/base-link.tsx | 169 -- .../src/components/link/base-link.types.ts | 20 - .../joint-react/src/components/link/index.ts | 62 +- .../components/link/link-label.stories.tsx | 101 - .../src/components/link/link-label.tsx | 181 -- .../src/components/link/link-label.types.ts | 53 - .../graph-provider-controlled-mode.test.tsx | 352 ++-- .../graph-provider-coverage.test.tsx | 46 +- .../paper/__tests__/graph-provider.test.tsx | 227 ++- .../paper-html-overlay-links.test.tsx | 162 +- .../components/paper/__tests__/paper.test.tsx | 69 +- .../src/components/paper/paper.stories.tsx | 33 +- .../src/components/paper/paper.tsx | 68 +- .../src/components/paper/paper.types.ts | 13 +- .../render-element/paper-element-item.tsx | 26 +- .../port/__tests__/port-group.test.tsx | 6 +- .../port/__tests__/port-item.test.tsx | 6 +- .../components/port/__tests__/port.test.tsx | 209 ++- .../components/port/port-group.stories.tsx | 26 +- .../src/components/port/port-item.stories.tsx | 25 +- .../hooks/__tests__/use-cell-actions.test.tsx | 344 +++- .../src/hooks/__tests__/use-cell-id.test.tsx | 6 +- .../src/hooks/__tests__/use-element.test.tsx | 6 +- .../src/hooks/__tests__/use-elements.test.ts | 26 +- .../src/hooks/__tests__/use-graph.test.ts | 6 +- .../hooks/__tests__/use-link-layout.test.tsx | 84 + .../src/hooks/__tests__/use-links.test.ts | 20 +- .../__tests__/use-paper-context.test.tsx | 6 +- packages/joint-react/src/hooks/index.ts | 1 + .../src/hooks/use-cell-actions.stories.tsx | 5 +- .../joint-react/src/hooks/use-cell-actions.ts | 121 +- packages/joint-react/src/hooks/use-element.ts | 9 +- .../src/hooks/use-elements.stories.tsx | 44 +- .../joint-react/src/hooks/use-elements.ts | 28 +- .../src/hooks/use-graph-store-selector.ts | 15 - .../joint-react/src/hooks/use-link-layout.ts | 63 + .../src/hooks/use-links.stories.tsx | 26 +- packages/joint-react/src/hooks/use-links.ts | 44 +- .../joint-react/src/hooks/use-node-layout.ts | 2 +- .../src/hooks/use-state-to-external-store.ts | 16 +- packages/joint-react/src/index.ts | 7 +- .../__tests__/react-element-view.test.ts | 8 - .../models/__tests__/react-element.test.ts | 7 + .../models/__tests__/react-link-view.test.ts | 8 - .../src/models/__tests__/react-link.test.ts | 63 + .../src/models/__tests__/react-paper.test.ts | 294 +++ .../models/__tests__/view-extension.test.ts | 39 - .../src/models/react-element-view.ts | 68 - .../joint-react/src/models/react-element.tsx | 5 + .../joint-react/src/models/react-link-view.ts | 121 -- packages/joint-react/src/models/react-link.ts | 23 +- .../joint-react/src/models/react-paper.ts | 127 ++ .../src/models/react-paper.types.ts | 28 + .../__tests__/graph-state-selectors.test.ts | 396 ++-- .../src/state/__tests__/state-sync.test.ts | 1637 ++++++++--------- .../src/state/graph-state-selectors.ts | 62 +- packages/joint-react/src/state/state-sync.ts | 887 +++------ .../joint-react/src/state/update-graph.ts | 179 ++ .../src/store/__tests__/graph-store.test.ts | 158 +- packages/joint-react/src/store/batch-cache.ts | 125 ++ packages/joint-react/src/store/clear-view.ts | 130 ++ .../store/create-elements-size-observer.ts | 24 +- packages/joint-react/src/store/graph-store.ts | 1261 +++---------- packages/joint-react/src/store/paper-store.ts | 83 +- packages/joint-react/src/store/port-cache.ts | 149 ++ packages/joint-react/src/store/state-flush.ts | 193 ++ .../src/stories/demos/flowchart/code.tsx | 79 +- .../stories/demos/introduction-demo/code.tsx | 28 +- .../src/stories/demos/pulsing-port/code.tsx | 29 +- .../src/stories/demos/user-flow/code.tsx | 30 +- .../src/stories/examples/stress/code.tsx | 89 +- .../examples/with-auto-layout/code.tsx | 30 +- .../examples/with-build-in-shapes/code.tsx | 68 +- .../src/stories/examples/with-card/code.tsx | 17 +- .../examples/with-cell-actions/code.tsx | 533 ++++++ .../examples/with-cell-actions/story.tsx | 20 + .../code-with-create-links-classname.tsx | 17 +- .../code-with-create-links.tsx | 17 +- .../with-custom-link/code-with-dia-links.tsx | 17 +- .../examples/with-highlighter/code.tsx | 21 +- .../examples/with-intersection/code.tsx | 21 +- .../stories/examples/with-link-tools/code.tsx | 17 +- .../stories/examples/with-list-node/code.tsx | 22 +- .../stories/examples/with-minimap/code.tsx | 17 +- .../with-node-update/code-add-remove-node.tsx | 29 +- .../with-node-update/code-with-color.tsx | 16 +- .../with-node-update/code-with-svg.tsx | 27 +- .../examples/with-node-update/code.tsx | 18 +- .../examples/with-proximity-link/code.tsx | 19 +- .../examples/with-render-link/code.tsx | 66 +- .../examples/with-resizable-node/code.tsx | 21 +- .../examples/with-rotable-node/code.tsx | 18 +- .../stories/examples/with-svg-node/code.tsx | 17 +- .../joint-react/src/stories/introduction.mdx | 14 +- .../code-controlled-mode-jotai.tsx | 55 +- .../code-controlled-mode-peerjs.tsx | 217 ++- .../code-controlled-mode-redux.tsx | 61 +- .../code-controlled-mode-zustand.tsx | 64 +- .../step-by-step/code-controlled-mode.tsx | 74 +- .../step-by-step/code-html-renderer.tsx | 20 +- .../tutorials/step-by-step/code-html.tsx | 19 +- .../tutorials/step-by-step/code-svg.tsx | 19 +- .../stories/tutorials/step-by-step/docs.mdx | 32 +- packages/joint-react/src/types/cell.types.ts | 4 - .../joint-react/src/types/element-types.ts | 4 - packages/joint-react/src/types/link-types.ts | 4 - .../joint-react/src/types/scheduler.types.ts | 40 + .../src/utils/__tests__/get-cell.test.ts | 25 +- .../src/utils/cell/cell-utilities.ts | 100 - .../src/utils/cell/listen-to-cell-change.ts | 35 +- packages/joint-react/src/utils/clear-view.ts | 9 +- packages/joint-react/src/utils/is.ts | 7 +- .../utils/joint-jsx/jsx-to-markup.stories.tsx | 15 +- .../joint-react/src/utils/scheduler-old.ts | 57 - packages/joint-react/src/utils/scheduler.ts | 23 +- .../joint-react/src/utils/test-wrappers.tsx | 12 +- yarn.lock | 258 ++- 128 files changed, 5861 insertions(+), 6646 deletions(-) delete mode 100644 packages/joint-react/src/components/link/__tests__/base-link.test.tsx delete mode 100644 packages/joint-react/src/components/link/__tests__/link-label.test.tsx delete mode 100644 packages/joint-react/src/components/link/base-link.stories.tsx delete mode 100644 packages/joint-react/src/components/link/base-link.tsx delete mode 100644 packages/joint-react/src/components/link/base-link.types.ts delete mode 100644 packages/joint-react/src/components/link/link-label.stories.tsx delete mode 100644 packages/joint-react/src/components/link/link-label.tsx delete mode 100644 packages/joint-react/src/components/link/link-label.types.ts create mode 100644 packages/joint-react/src/hooks/__tests__/use-link-layout.test.tsx create mode 100644 packages/joint-react/src/hooks/use-link-layout.ts delete mode 100644 packages/joint-react/src/models/__tests__/react-element-view.test.ts delete mode 100644 packages/joint-react/src/models/__tests__/react-link-view.test.ts create mode 100644 packages/joint-react/src/models/__tests__/react-link.test.ts create mode 100644 packages/joint-react/src/models/__tests__/react-paper.test.ts delete mode 100644 packages/joint-react/src/models/__tests__/view-extension.test.ts delete mode 100644 packages/joint-react/src/models/react-element-view.ts delete mode 100644 packages/joint-react/src/models/react-link-view.ts create mode 100644 packages/joint-react/src/models/react-paper.ts create mode 100644 packages/joint-react/src/models/react-paper.types.ts create mode 100644 packages/joint-react/src/state/update-graph.ts create mode 100644 packages/joint-react/src/store/batch-cache.ts create mode 100644 packages/joint-react/src/store/clear-view.ts create mode 100644 packages/joint-react/src/store/port-cache.ts create mode 100644 packages/joint-react/src/store/state-flush.ts create mode 100644 packages/joint-react/src/stories/examples/with-cell-actions/code.tsx create mode 100644 packages/joint-react/src/stories/examples/with-cell-actions/story.tsx delete mode 100644 packages/joint-react/src/types/cell.types.ts create mode 100644 packages/joint-react/src/types/scheduler.types.ts delete mode 100644 packages/joint-react/src/utils/cell/cell-utilities.ts delete mode 100644 packages/joint-react/src/utils/scheduler-old.ts diff --git a/packages/joint-react/.storybook/decorators/with-simple-data.tsx b/packages/joint-react/.storybook/decorators/with-simple-data.tsx index 6b4751625..684ee4ae2 100644 --- a/packages/joint-react/.storybook/decorators/with-simple-data.tsx +++ b/packages/joint-react/.storybook/decorators/with-simple-data.tsx @@ -12,8 +12,18 @@ import { Paper } from '../../src/components/paper/paper'; export type StoryFunction = PartialStoryFn; export type StoryCtx = StoryContext; -export const testElements = [ - { +export const testElements: Record = { + '1': { id: '1', label: 'Node 1', color: PRIMARY, @@ -24,7 +34,7 @@ export const testElements = [ hoverColor: 'red', angle: 0, }, - { + '2': { id: '2', label: 'Node 2', color: PRIMARY, @@ -35,11 +45,11 @@ export const testElements = [ hoverColor: 'blue', angle: 0, }, -]; +}; -export type SimpleElement = (typeof testElements)[number]; -export const testLinks: GraphLink[] = [ - { +export type SimpleElement = (typeof testElements)[string]; +export const testLinks: Record = { + 'l-1': { id: 'l-1', source: '1', target: '2', @@ -49,7 +59,7 @@ export const testLinks: GraphLink[] = [ }, }, }, -]; +}; export function SimpleGraphProviderDecorator({ children }: Readonly) { return ( diff --git a/packages/joint-react/README.md b/packages/joint-react/README.md index e4879933a..7b767bccf 100644 --- a/packages/joint-react/README.md +++ b/packages/joint-react/README.md @@ -56,17 +56,17 @@ bun add @joint/react import React from 'react' import { GraphProvider } from '@joint/react' -const elements = [ - { id: 'node1', label: 'Start', x: 100, y: 50, width: 120, height: 60 }, - { id: 'node2', label: 'End', x: 100, y: 200, width: 120, height: 60 }, -] as const +const elements = { + 'node1': { id: 'node1', label: 'Start', x: 100, y: 50, width: 120, height: 60 }, + 'node2': { id: 'node2', label: 'End', x: 100, y: 200, width: 120, height: 60 }, +} as const -const links = [ - { id: 'link1', source: 'node1', target: 'node2' }, -] as const +const links = { + 'link1': { id: 'link1', source: 'node1', target: 'node2' }, +} as const -// Narrow element type straight from the array: -type Element = typeof elements[number] +// Narrow element type straight from the record: +type Element = typeof elements[keyof typeof elements] export default function App() { return ( @@ -102,12 +102,14 @@ Share one diagram across views. Give each view a stable `id`. import React from 'react' import { GraphProvider } from '@joint/react' -const elements = [ - { id: 'a' as const, label: 'A', x: 40, y: 60, width: 80, height: 40 }, - { id: 'b' as const, label: 'B', x: 260, y: 180, width: 80, height: 40 }, -] as const +const elements = { + 'a': { id: 'a' as const, label: 'A', x: 40, y: 60, width: 80, height: 40 }, + 'b': { id: 'b' as const, label: 'B', x: 260, y: 180, width: 80, height: 40 }, +} as const -const links = [{ id: 'a-b' as const, source: 'a', target: 'b' }] as const +const links = { + 'a-b': { id: 'a-b' as const, source: 'a', target: 'b' }, +} as const export function MultiView() { return ( @@ -166,15 +168,15 @@ Pass `elements/links` + `onElementsChange/onLinksChange` to keep React in charge import React, { useState } from 'react' import { GraphProvider } from '@joint/react' -const initialElements = [ - { id: 'n1' as const, label: 'Item', x: 60, y: 60, width: 100, height: 40 }, -] as const +const initialElements = { + 'n1': { id: 'n1' as const, label: 'Item', x: 60, y: 60, width: 100, height: 40 }, +} as const -const initialLinks = [] as const +const initialLinks = {} as const export function Controlled() { - const [els, setEls] = useState([...initialElements]) - const [lns, setLns] = useState([...initialLinks]) + const [els, setEls] = useState>({ ...initialElements }) + const [lns, setLns] = useState>({ ...initialLinks }) return ( ({ - elements: [], - links: [], + elements: {}, + links: {}, setState: (updater) => set(updater), getSnapshot: () => useGraphStore.getState(), subscribe: (listener) => { @@ -287,7 +289,7 @@ export function FitOnMount() { ## 🧠 Best Practices - **Define ids as literals**: `id: 'node1' as const` β€” enables exact typing and prevents mismatches. -- **Type elements from data**: `type Element = typeof elements[number]` β€” reuse data as your source of truth. +- **Type elements from data**: `type Element = typeof elements[keyof typeof elements]` β€” reuse data as your source of truth. - **Memoize renderers & handlers**: `useCallback` to minimize re-renders. - **Keep overlay HTML lightweight**: Prefer simple layout; avoid heavy transforms/animations in `` (Safari can be picky). - **Give each view a stable `id`** when rendering multiple `Paper` instances. diff --git a/packages/joint-react/jest.config.js b/packages/joint-react/jest.config.js index 651d74e32..6d062dced 100644 --- a/packages/joint-react/jest.config.js +++ b/packages/joint-react/jest.config.js @@ -2,7 +2,22 @@ export default { testEnvironment: 'jsdom', testPathIgnorePatterns: ['/node_modules/', '/dist/'], transform: { - '^.+\\.tsx?$': 'ts-jest', // Transform TypeScript files + '^.+\\.tsx?$': [ + '@swc/jest', + { + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + }, + transform: { + react: { + runtime: 'automatic', + }, + }, + }, + }, + ], }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], // Recognize file extensions setupFilesAfterEnv: [ diff --git a/packages/joint-react/package.json b/packages/joint-react/package.json index d769c1775..a330dbd15 100644 --- a/packages/joint-react/package.json +++ b/packages/joint-react/package.json @@ -65,6 +65,8 @@ "@storybook/react": "8.6.12", "@storybook/react-vite": "8.6.12", "@storybook/test": "8.6.12", + "@swc/core": "^1.15.11", + "@swc/jest": "^0.2.39", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/react-hooks": "^8.0.1", @@ -98,7 +100,6 @@ "storybook": "^8.6.14", "storybook-addon-performance": "0.17.3", "storybook-multilevel-sort": "2.1.0", - "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typedoc": "^0.28.5", "typedoc-github-theme": "^0.3.0", diff --git a/packages/joint-react/src/components/graph/graph-provider.stories.tsx b/packages/joint-react/src/components/graph/graph-provider.stories.tsx index 26ce34ea8..8e7fc33d6 100644 --- a/packages/joint-react/src/components/graph/graph-provider.stories.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.stories.tsx @@ -35,19 +35,19 @@ The **GraphProvider** component provides a shared Graph context for all its desc \`\`\`tsx import { GraphProvider, Paper } from '@joint/react'; -const elements = [ - { id: '1', x: 100, y: 100, width: 100, height: 50 }, - { id: '2', x: 250, y: 200, width: 100, height: 50 }, -]; +const elements = { + '1': { id: '1', x: 100, y: 100, width: 100, height: 50 }, + '2': { id: '2', x: 250, y: 200, width: 100, height: 50 }, +}; -const links = [ - { id: 'l1', source: '1', target: '2' }, -]; +const links = { + 'l1': { id: 'l1', source: '1', target: '2' }, +}; function MyDiagram() { return ( - ( )} @@ -58,8 +58,8 @@ function MyDiagram() { \`\`\` `, props: ` -- **elements**: Array of element objects (required) -- **links**: Array of link objects (required) +- **elements**: Record of element objects keyed by ID (required) +- **links**: Record of link objects keyed by ID (required) - **children**: React nodes (typically Paper components) - **onChange**: Callback fired when graph state changes `, diff --git a/packages/joint-react/src/components/graph/graph-provider.tsx b/packages/joint-react/src/components/graph/graph-provider.tsx index b3ec9ee04..6c00c15d9 100644 --- a/packages/joint-react/src/components/graph/graph-provider.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.tsx @@ -11,10 +11,15 @@ import type { GraphStateSelectors } from '../../state/graph-state-selectors'; /** * Props for GraphProvider component. * Supports three modes: uncontrolled, React-controlled, and external-store-controlled. + * @template Element - The type of elements in the graph + * @template Link - The type of links in the graph */ -interface GraphProviderProps { +interface GraphProviderProps< + Element extends GraphElement = GraphElement, + Link extends GraphLink = GraphLink, +> { /** - * Elements (nodes) to be added to the graph. + * Elements (nodes) to be added to the graph as a Record keyed by cell ID. * * **Controlled mode:** When `onElementsChange` is provided, this prop controls the elements. * All changes must go through React state updates. @@ -22,10 +27,10 @@ interface GraphProviderProps { * **Uncontrolled mode:** If `onElementsChange` is not provided, this is only used for initial elements. * The graph manages its own state internally. */ - readonly elements?: GraphElement[]; + readonly elements?: Record; /** - * Links (edges) to be added to the graph. + * Links (edges) to be added to the graph as a Record keyed by cell ID. * * **Controlled mode:** When `onLinksChange` is provided, this prop controls the links. * All changes must go through React state updates. @@ -33,7 +38,7 @@ interface GraphProviderProps { * **Uncontrolled mode:** If `onLinksChange` is not provided, this is only used for initial links. * The graph manages its own state internally. */ - readonly links?: GraphLink[]; + readonly links?: Record; /** * Callback triggered when elements (nodes) change in the graph. @@ -47,7 +52,7 @@ interface GraphProviderProps { * - State persistence * - Integration with other React state management */ - readonly onElementsChange?: Dispatch>; + readonly onElementsChange?: Dispatch>>; /** * Callback triggered when links (edges) change in the graph. @@ -61,16 +66,7 @@ interface GraphProviderProps { * - State persistence * - Integration with other React state management */ - readonly onLinksChange?: Dispatch>; - - // readonly linkMapper: { - // toStateSelector: (data: GraphLink) => dia.Link.JSON; - // toDiaGraphSelector: (link: dia.Link.JSON) => GraphLink; - // }; - // readonly elementMapper: { - // toStateSelector: (data: GraphElement) => dia.Element.JSON; - // toDiaGraphSelector: (element: dia.Element.JSON) => GraphElement; - // }; + readonly onLinksChange?: Dispatch>>; } /** @@ -82,7 +78,7 @@ interface GraphProviderProps { export interface GraphProps< Element extends GraphElement = GraphElement, Link extends GraphLink = GraphLink, -> extends GraphProviderProps, +> extends GraphProviderProps, GraphStateSelectors { /** * Graph instance to use. If not provided, a new graph instance will be created. @@ -142,13 +138,6 @@ export interface GraphProps< * with most state management libraries. */ readonly externalStore?: ExternalGraphStore; - - /** - * If true, batch updates are disabled and synchronization will be real-time. - * If false (default), batch updates are enabled for better performance. - * @default false - */ - readonly areBatchUpdatesDisabled?: boolean; } /** @@ -296,8 +285,8 @@ const GraphBaseRouter = forwardRef( * * 2. **React-controlled mode:** * ```tsx - * const [elements, setElements] = useState([]); - * const [links, setLinks] = useState([]); + * const [elements, setElements] = useState>({}); + * const [links, setLinks] = useState>({}); * * { wrapper: paperRenderElementWrapper({ graphProviderProps: { graph, - elements: [ - { + elements: { + 'element-1': { id: 'element-1', x: 0, y: 0, width: 100, height: 100, }, - ], + }, }, }), }; diff --git a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx index fef04ae6b..7ffc6ca0a 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx @@ -5,13 +5,13 @@ import { Stroke } from '../stroke'; describe('Stroke highlighter', () => { const wrapper = paperRenderElementWrapper({ graphProviderProps: { - elements: [ - { + elements: { + '1': { id: '1', width: 100, height: 100, }, - ], + }, }, paperProps: { renderElement: () => , diff --git a/packages/joint-react/src/components/link/__tests__/base-link.test.tsx b/packages/joint-react/src/components/link/__tests__/base-link.test.tsx deleted file mode 100644 index 5e4544a53..000000000 --- a/packages/joint-react/src/components/link/__tests__/base-link.test.tsx +++ /dev/null @@ -1,224 +0,0 @@ -/* eslint-disable unicorn/consistent-function-scoping */ - -import { render, waitFor } from '@testing-library/react'; -import { getTestGraph, paperRenderLinkWrapper } from '../../../utils/test-wrappers'; -import { dia } from '@joint/core'; -import { BaseLink } from '../base-link'; - -describe('BaseLink', () => { - const getTestWrapper = () => { - const graph = getTestGraph(); - return { - graph, - wrapper: paperRenderLinkWrapper({ - graphProviderProps: { - graph, - elements: [ - { - id: 'element-1', - x: 0, - y: 0, - width: 100, - height: 100, - }, - { - id: 'element-2', - x: 200, - y: 200, - width: 100, - height: 100, - }, - ], - links: [ - { - id: 'link-1', - source: 'element-1', - target: 'element-2', - }, - ], - }, - }), - }; - }; - - it('should set stroke attribute correctly', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render(, { wrapper }); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - expect(link).toBeInstanceOf(dia.Link); - const line = link.attr('line'); - expect(line?.stroke).toBe('blue'); - }); - unmount(); - }); - - it('should set strokeWidth attribute correctly', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render(, { wrapper }); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.strokeWidth).toBe(3); - }); - unmount(); - }); - - it('should set strokeDasharray attribute correctly', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render(, { wrapper }); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.strokeDasharray).toBe('5,5'); - }); - unmount(); - }); - - it('should set strokeDashoffset attribute correctly', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render(, { wrapper }); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.strokeDashoffset).toBe(10); - }); - unmount(); - }); - - it('should set strokeLinecap attribute correctly', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render(, { wrapper }); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.strokeLinecap).toBe('round'); - }); - unmount(); - }); - - it('should set strokeLinejoin attribute correctly', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render(, { wrapper }); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.strokeLinejoin).toBe('bevel'); - }); - unmount(); - }); - - it('should set fill attribute correctly', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render(, { wrapper }); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.fill).toBe('red'); - }); - unmount(); - }); - - it('should set opacity attribute correctly', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render(, { wrapper }); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.opacity).toBe(0.5); - }); - unmount(); - }); - - it('should set multiple attributes correctly', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render( - , - { wrapper } - ); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.stroke).toBe('green'); - expect(line?.strokeWidth).toBe(4); - expect(line?.strokeDasharray).toBe('10,5'); - expect(line?.opacity).toBe(0.8); - }); - unmount(); - }); - - it('should restore default attributes on unmount', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render(, { wrapper }); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.stroke).toBe('blue'); - expect(line?.strokeWidth).toBe(5); - }); - unmount(); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.stroke).toBe('#333333'); - }); - }); - - it('should update attributes when props change', async () => { - const { graph, wrapper } = getTestWrapper(); - const { rerender } = render(, { wrapper }); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.stroke).toBe('red'); - }); - - rerender(); - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link) { - throw new Error('Link not found in graph'); - } - const line = link.attr('line'); - expect(line?.stroke).toBe('purple'); - expect(line?.strokeWidth).toBe(6); - }); - }); -}); diff --git a/packages/joint-react/src/components/link/__tests__/link-label.test.tsx b/packages/joint-react/src/components/link/__tests__/link-label.test.tsx deleted file mode 100644 index c9c537510..000000000 --- a/packages/joint-react/src/components/link/__tests__/link-label.test.tsx +++ /dev/null @@ -1,511 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable @typescript-eslint/no-shadow */ -/* eslint-disable no-shadow */ -/* eslint-disable @typescript-eslint/prefer-optional-chain */ -/* eslint-disable unicorn/consistent-function-scoping */ -/* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { render, waitFor, act } from '@testing-library/react'; -import { getTestGraph, paperRenderLinkWrapper } from '../../../utils/test-wrappers'; -import type { dia } from '@joint/core'; -import { LinkLabel } from '../link-label'; - -interface LinkLabelWithId extends dia.Link.Label { - readonly labelId: string; -} - -function isLabelPosition( - position: number | dia.LinkView.LabelOptions | undefined -): position is dia.LinkView.LabelOptions { - // eslint-disable-next-line sonarjs/different-types-comparison - return typeof position === 'object' && position !== null; -} - -function getLabelPosition(label: LinkLabelWithId): dia.LinkView.LabelOptions { - const { position } = label; - if (!isLabelPosition(position)) { - throw new Error('Expected label position to be an object'); - } - return position; -} - -describe('LinkLabel', () => { - const getTestWrapper = () => { - const graph = getTestGraph(); - return { - graph, - wrapper: paperRenderLinkWrapper({ - graphProviderProps: { - graph, - elements: [ - { - id: 'element-1', - x: 0, - y: 0, - width: 100, - height: 100, - }, - { - id: 'element-2', - x: 200, - y: 200, - width: 100, - height: 100, - }, - ], - links: [ - { - id: 'link-1', - source: 'element-1', - target: 'element-2', - }, - ], - }, - }), - }; - }; - - describe('mount and unmount', () => { - it('should create label on mount', async () => { - const { graph, wrapper } = getTestWrapper(); - render( - - Test Label - , - { wrapper } - ); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - expect(labels.length).toBe(1); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).distance).toBe(0.5); - expect(label.labelId).toBeDefined(); - }); - }); - - it('should remove label on unmount', async () => { - const { graph, wrapper } = getTestWrapper(); - const { unmount } = render( - - Test Label - , - { wrapper } - ); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - expect(link.labels().length).toBe(1); - }); - - unmount(); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - expect(link.labels().length).toBe(0); - }); - }); - }); - - describe('prop updates', () => { - it('should update distance when prop changes', async () => { - const { graph, wrapper } = getTestWrapper(); - const { rerender } = render(, { wrapper }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - expect(labels.length).toBe(1); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).distance).toBe(0.3); - }); - - rerender(); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - expect(labels.length).toBe(1); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).distance).toBe(0.7); - }); - }); - - it('should update offset when prop changes', async () => { - const { graph, wrapper } = getTestWrapper(); - const { rerender } = render(, { wrapper }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).offset).toBe(10); - }); - - rerender(); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).offset).toEqual({ x: 20, y: 30 }); - }); - }); - - it('should update angle when prop changes', async () => { - const { graph, wrapper } = getTestWrapper(); - const { rerender } = render(, { wrapper }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).angle).toBe(0); - }); - - rerender(); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).angle).toBe(45); - }); - }); - - it('should update attrs when prop changes', async () => { - const { graph, wrapper } = getTestWrapper(); - const { rerender } = render( - , - { wrapper } - ); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(label.attrs?.text?.text).toBe('Label 1'); - }); - - rerender(); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(label.attrs?.text?.text).toBe('Label 2'); - }); - }); - - it('should update size when prop changes', async () => { - const { graph, wrapper } = getTestWrapper(); - const { rerender } = render(, { - wrapper, - }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(label.size?.width).toBe(50); - expect(label.size?.height).toBe(20); - }); - - rerender(); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(label.size?.width).toBe(100); - expect(label.size?.height).toBe(40); - }); - }); - - it('should update args when prop changes', async () => { - const { graph, wrapper } = getTestWrapper(); - const { rerender } = render(, { - wrapper, - }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).args?.absoluteDistance).toBe(true); - }); - - rerender(); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).args?.absoluteDistance).toBe(false); - }); - }); - - it('should update multiple props together', async () => { - const { graph, wrapper } = getTestWrapper(); - const { rerender } = render( - , - { wrapper } - ); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - const position = getLabelPosition(label); - expect(position.distance).toBe(0.3); - expect(position.offset).toBe(10); - expect(position.angle).toBe(0); - expect(label.attrs?.text?.text).toBe('A'); - }); - - rerender(); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - const position = getLabelPosition(label); - expect(position.distance).toBe(0.7); - expect(position.offset).toBe(20); - expect(position.angle).toBe(90); - expect(label.attrs?.text?.text).toBe('B'); - }); - }); - }); - - describe('render optimization', () => { - it('should not create duplicate labels when props change', async () => { - const { graph, wrapper } = getTestWrapper(); - const { rerender } = render(, { wrapper }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - expect(link.labels().length).toBe(1); - }); - - // Update props multiple times - rerender(); - rerender(); - rerender(); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - // Should still have only one label - expect(link.labels().length).toBe(1); - const label = link.labels()[0] as LinkLabelWithId; - expect(getLabelPosition(label).distance).toBe(0.8); - }); - }); - - it('should not re-create label when stable props change', async () => { - const { graph, wrapper } = getTestWrapper(); - let renderCount = 0; - const TestComponent = () => { - renderCount++; - return Test; - }; - - const { rerender } = render(, { wrapper }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - expect(link.labels().length).toBe(1); - }); - - // Get the initial labelId - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const initialLabelId = (link.labels()[0] as LinkLabelWithId).labelId; - - // Update props that should trigger update effect, not create effect - act(() => { - rerender(); - }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - // Label should still exist with same labelId - const labels = link.labels(); - expect(labels.length).toBe(1); - const label = labels[0] as LinkLabelWithId; - expect(label.labelId).toBe(initialLabelId); - }); - - // Component may re-render, but label should not be recreated - expect(link.labels().length).toBe(1); - }); - - it('should only create label once on mount, then update on prop changes', async () => { - const { graph, wrapper } = getTestWrapper(); - const { rerender } = render(, { wrapper }); - - // Wait for link to be available and label to be created - await waitFor(() => { - const foundLink = graph.getCell('link-1'); - if (!foundLink || !foundLink.isLink()) { - throw new Error('Link not found'); - } - expect(foundLink.labels().length).toBe(1); - }); - - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - - // Get the initial labelId to verify it's the same label being updated - const initialLabelId = (link.labels()[0] as LinkLabelWithId).labelId; - - // Update props - should trigger update effect, not create effect - rerender(); - await waitFor(() => { - const labels = link!.labels(); - expect(labels.length).toBe(1); - const label = labels[0] as LinkLabelWithId; - expect(label.labelId).toBe(initialLabelId); // Same label, not a new one - expect(getLabelPosition(label).distance).toBe(0.6); - }); - - // Update again - rerender(); - await waitFor(() => { - const labels = link!.labels(); - expect(labels.length).toBe(1); - const label = labels[0] as LinkLabelWithId; - expect(label.labelId).toBe(initialLabelId); // Still the same label - expect(getLabelPosition(label).distance).toBe(0.7); - }); - - // Should still have only one label - const finalLink = graph.getCell('link-1'); - if (!finalLink || !finalLink.isLink()) { - throw new Error('Link not found'); - } - expect(finalLink.labels().length).toBe(1); - const finalLabel = finalLink.labels()[0] as LinkLabelWithId; - expect(finalLabel.labelId).toBe(initialLabelId); // Same label throughout - }); - }); - - describe('edge cases', () => { - it('should handle ensureLegibility prop', async () => { - const { graph, wrapper } = getTestWrapper(); - render(, { wrapper }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).args?.ensureLegibility).toBe(true); - }); - }); - - it('should handle keepGradient prop', async () => { - const { graph, wrapper } = getTestWrapper(); - render(, { wrapper }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - const label = labels[0] as LinkLabelWithId; - expect(getLabelPosition(label).args?.keepGradient).toBe(true); - }); - }); - - it('should handle undefined optional props', async () => { - const { graph, wrapper } = getTestWrapper(); - render(, { wrapper }); - - await waitFor(() => { - const link = graph.getCell('link-1'); - if (!link || !link.isLink()) { - throw new Error('Link not found'); - } - const labels = link.labels(); - expect(labels.length).toBe(1); - const label = labels[0] as LinkLabelWithId; - const position = getLabelPosition(label); - expect(position.distance).toBe(0.5); - // Optional props should be undefined or have defaults - expect(position.offset).toBeUndefined(); - expect(position.angle).toBeUndefined(); - }); - }); - }); -}); diff --git a/packages/joint-react/src/components/link/base-link.stories.tsx b/packages/joint-react/src/components/link/base-link.stories.tsx deleted file mode 100644 index 66cf59946..000000000 --- a/packages/joint-react/src/components/link/base-link.stories.tsx +++ /dev/null @@ -1,326 +0,0 @@ -/* eslint-disable react-perf/jsx-no-new-function-as-prop */ -import type { Meta, StoryObj } from '@storybook/react'; -import '../../stories/examples/index.css'; -import { getAPILink } from '../../stories/utils/get-api-documentation-link'; -import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; -import { SimpleRenderLinkDecorator } from 'storybook-config/decorators/with-simple-data'; -import { BaseLink } from './base-link'; - -export type Story = StoryObj; -const API_URL = getAPILink('Link.BaseLink', 'variables'); - -const meta: Meta = { - title: 'Components/Link/BaseLink', - component: BaseLink, - decorators: [SimpleRenderLinkDecorator], - tags: ['component'], - parameters: makeRootDocumentation({ - apiURL: API_URL, - description: ` -The **Link.BaseLink** component sets link properties when rendering custom links. It must be used inside the \`renderLink\` function. - -**Key Features:** -- Sets link attributes (stroke, strokeWidth, etc.) -- Sets link markup -- Sets other link properties -- Must be used inside renderLink context - `, - usage: ` -\`\`\`tsx -import { Link } from '@joint/react'; - -function RenderLink({ id }) { - return ( - <> - - - ); -} -\`\`\` - `, - props: ` -- **attrs**: Link attributes to apply -- **markup**: Link markup to use for rendering -- **...rest**: Additional link properties - `, - code: `import { Link } from '@joint/react'; - - - `, - }), -}; - -export default meta; - -export const Default = makeStory({ - args: { - stroke: 'blue', - }, - apiURL: API_URL, - name: 'Basic link', -}); - -export const WithArrowMarkers = makeStory({ - args: { - stroke: '#0075f2', - strokeWidth: 2, - startMarker: 'arrow', - endMarker: 'arrow', - }, - apiURL: API_URL, - name: 'Arrow markers', -}); - -export const WithArrowRoundedMarkers = makeStory({ - args: { - stroke: '#ed2637', - strokeWidth: 2, - startMarker: 'arrow-rounded', - endMarker: 'arrow-rounded', - }, - apiURL: API_URL, - name: 'Rounded arrow markers', -}); - -export const WithTriangleMarkers = makeStory({ - args: { - stroke: '#28a745', - strokeWidth: 2, - startMarker: 'triangle', - endMarker: 'triangle', - }, - apiURL: API_URL, - name: 'Triangle markers', -}); - -export const WithDiamondMarkers = makeStory({ - args: { - stroke: '#ffc107', - strokeWidth: 2, - startMarker: 'diamond', - endMarker: 'diamond', - }, - apiURL: API_URL, - name: 'Diamond markers', -}); - -export const WithCircleMarkers = makeStory({ - args: { - stroke: '#6f42c1', - strokeWidth: 2, - startMarker: 'circle', - endMarker: 'circle', - }, - apiURL: API_URL, - name: 'Circle markers', -}); - -export const WithDifferentStartEndMarkers = makeStory({ - args: { - stroke: '#17a2b8', - strokeWidth: 2, - startMarker: 'arrow', - endMarker: 'circle', - }, - apiURL: API_URL, - name: 'Different start and end markers', -}); - -export const WithCrossMarkers = makeStory({ - args: { - stroke: '#dc3545', - strokeWidth: 2, - startMarker: 'cross', - endMarker: 'cross', - }, - apiURL: API_URL, - name: 'Cross markers', -}); - -export const WithLineMarkers = makeStory({ - args: { - stroke: '#20c997', - strokeWidth: 2, - startMarker: 'line', - endMarker: 'line', - }, - apiURL: API_URL, - name: 'Line markers', -}); - -export const WithOpenMarkers = makeStory({ - args: { - stroke: '#fd7e14', - strokeWidth: 2, - startMarker: 'circle-open', - endMarker: 'triangle-open', - }, - apiURL: API_URL, - name: 'Open markers', -}); - -export const WithRoundedDiamondMarkers = makeStory({ - args: { - stroke: '#e83e8c', - strokeWidth: 2, - startMarker: 'diamond-rounded', - endMarker: 'diamond-rounded', - }, - apiURL: API_URL, - name: 'Rounded diamond markers', -}); - -export const NoMarkers = makeStory({ - args: { - stroke: '#6c757d', - strokeWidth: 2, - startMarker: 'none', - endMarker: 'none', - }, - apiURL: API_URL, - name: 'No markers', -}); - -// Custom marker function stories -function CustomStarMarkerStory() { - return ( - ( - - )} - endMarker={(props) => ( - - )} - /> - ); -} - -export const WithCustomStarMarkers = makeStory({ - component: CustomStarMarkerStory, - apiURL: API_URL, - name: 'Custom star markers', -}); - -function CustomSquareMarkerStory() { - return ( - ( - - )} - endMarker={(props) => ( - - )} - /> - ); -} - -export const WithCustomSquareMarkers = makeStory({ - component: CustomSquareMarkerStory, - apiURL: API_URL, - name: 'Custom square markers', -}); - -function CustomHeartMarkerStory() { - return ( - ( - - )} - endMarker={(props) => ( - - )} - /> - ); -} - -export const WithCustomHeartMarkers = makeStory({ - component: CustomHeartMarkerStory, - apiURL: API_URL, - name: 'Custom heart markers', -}); - -function CustomMixedMarkersStory() { - return ( - ( - - )} - /> - ); -} - -export const WithMixedPredefinedAndCustomMarkers = makeStory({ - component: CustomMixedMarkersStory, - apiURL: API_URL, - name: 'Mixed predefined and custom markers', -}); - -function CustomComplexMarkerStory() { - return ( - ( - - - - - )} - endMarker={(props) => ( - - - - - )} - /> - ); -} - -export const WithCustomComplexMarkers = makeStory({ - component: CustomComplexMarkerStory, - apiURL: API_URL, - name: 'Custom complex markers', -}); diff --git a/packages/joint-react/src/components/link/base-link.tsx b/packages/joint-react/src/components/link/base-link.tsx deleted file mode 100644 index 03070ea3b..000000000 --- a/packages/joint-react/src/components/link/base-link.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { memo, useLayoutEffect, useMemo } from 'react'; -import { useGraphStore } from '../../hooks/use-graph-store'; -import { useCellId, usePaperStoreContext } from '../../hooks'; -import type { StandardLinkShapesTypeMapper } from '../../types/link-types'; -import { getLinkArrow, type LinkArrowName, type MarkerProps } from './link.arrows'; -import { jsx } from '../../utils/joint-jsx/jsx-to-markup'; -import type React from 'react'; -import type { OmitWithoutIndexSignature } from '../../types'; - -type StandardLinkAttributes = Required; -type LineAttributes = StandardLinkAttributes['line']; - -/** - * Marker configuration - either a predefined arrow name or a direct component function. - */ -export type MarkerConfig = LinkArrowName | ((props: MarkerProps) => React.JSX.Element); - -export interface BaseLinkProps - extends OmitWithoutIndexSignature { - /** - * Arrow marker for the start of the link. - * Can be a predefined arrow name from LINK_ARROWS or a direct component function. - */ - readonly startMarker?: MarkerConfig; - /** - * Arrow marker for the end of the link. - * Can be a predefined arrow name from LINK_ARROWS or a direct component function. - */ - readonly endMarker?: MarkerConfig; -} - -// eslint-disable-next-line jsdoc/require-jsdoc -function Component(props: BaseLinkProps) { - const { startMarker, endMarker, ...lineAttributes } = props; - const linkId = useCellId(); - const graphStore = useGraphStore(); - const { graph } = graphStore; - const { paper } = usePaperStoreContext(); - - const resolvedLineAttributes = useMemo(() => { - const resolved: typeof lineAttributes & { - sourceMarker?: { markup: ReturnType }; - targetMarker?: { markup: ReturnType }; - } = { ...lineAttributes }; - - // Get the link to check for color attribute - const link = graph.getCell(linkId); - const linkColor = link?.attr('color') as string | undefined; - - // Determine marker color: use stroke if provided, otherwise inherit from link color, fallback to black - const markerColor = (lineAttributes.stroke as string | undefined) ?? linkColor ?? '#000000'; - - if (startMarker) { - let markerComponent: ((props: MarkerProps) => React.JSX.Element) | undefined; - - // Check if it's a predefined marker name or direct function - if (typeof startMarker === 'string') { - const marker = getLinkArrow(startMarker); - markerComponent = marker?.component; - } else { - markerComponent = startMarker; - } - - if (markerComponent) { - // Convert React component to JointJS markup - const componentResult = markerComponent({ color: markerColor }); - resolved.sourceMarker = { markup: jsx(componentResult) }; - } - } - - if (endMarker) { - let markerComponent: ((props: MarkerProps) => React.JSX.Element) | undefined; - - // Check if it's a predefined marker name or direct function - if (typeof endMarker === 'string') { - const marker = getLinkArrow(endMarker); - markerComponent = marker?.component; - } else { - markerComponent = endMarker; - } - - if (markerComponent) { - // Convert React component to JointJS markup - const componentResult = markerComponent({ color: markerColor }); - resolved.targetMarker = { markup: jsx(componentResult) }; - } - } - - return resolved; - }, [graph, linkId, lineAttributes, startMarker, endMarker]); - - // Effect 1: Capture default attributes on mount, restore on unmount - // Only depends on paper and graph (stable references) - runs on mount/unmount - useLayoutEffect(() => { - const link = graph.getCell(linkId); - - if (!link) { - throw new Error(`Link with id ${linkId} not found`); - } - if (!paper) { - return; - } - if (!link.isLink()) { - throw new Error(`Cell with id ${linkId} is not a link`); - } - - // Capture default attributes for cleanup - const defaultAttributes = link.attr(); - - return () => { - // Restore default attributes via graphStore for batching - graphStore.setLink(linkId, defaultAttributes); - }; - }, [graph, graphStore, paper, linkId]); // Only stable dependencies - captures defaults on mount, restores on unmount - - // Effect 2: Update attributes when props change or when link is updated - useLayoutEffect(() => { - const link = graph.getCell(linkId); - - if (!link) { - return; - } - if (!paper) { - return; - } - if (!link.isLink()) { - return; - } - - // Always re-apply current attributes via graphStore for batching - graphStore.setLink(linkId, { - line: resolvedLineAttributes, - }); - graphStore.flushPendingUpdates(); - }, [graph, graphStore, resolvedLineAttributes, linkId, paper]); - - return null; -} - -/** - * BaseLink component sets link properties when rendering custom links. - * Must be used inside `renderLink` function. - * @group Components - * @category Link - * @example - * ```tsx - * function RenderLink({ id }) { - * return ( - * <> - * - * - * ); - * } - * ``` - * @example - * ```tsx - * function RenderLink({ id }) { - * return ( - * <> - * } - * /> - * - * ); - * } - * ``` - */ -export const BaseLink = memo(Component); diff --git a/packages/joint-react/src/components/link/base-link.types.ts b/packages/joint-react/src/components/link/base-link.types.ts deleted file mode 100644 index 372699d71..000000000 --- a/packages/joint-react/src/components/link/base-link.types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { dia } from '@joint/core'; - -/** - * Props for the BaseLink component. - * BaseLink is used to set link properties when rendering custom links. - */ -export interface BaseLinkProps { - /** - * Link attributes to apply to the link. - */ - readonly attrs?: dia.Link.Attributes; - /** - * Link markup to use for rendering. - */ - readonly markup?: dia.MarkupJSON; - /** - * Additional link properties. - */ - readonly [key: string]: unknown; -} diff --git a/packages/joint-react/src/components/link/index.ts b/packages/joint-react/src/components/link/index.ts index 6fb64836b..5bed4886a 100644 --- a/packages/joint-react/src/components/link/index.ts +++ b/packages/joint-react/src/components/link/index.ts @@ -1,61 +1,7 @@ -/* eslint-disable @typescript-eslint/no-namespace */ - -export * from './base-link.types'; -export * from './link-label.types'; -import { BaseLink } from './base-link'; -import { LinkLabel } from './link-label'; -export type { BaseLinkProps, MarkerConfig } from './base-link'; -export type { LinkLabelProps, LinkLabelPosition } from './link-label.types'; -export { LINK_ARROWS, getLinkArrow, type LinkArrowName, type LinkArrowMarker, type MarkerProps } from './link.arrows'; - -// Direct exports for convenience - -const Component = { - Base: BaseLink, - Label: LinkLabel, -}; - /** - * Joint js Links in react. - * Links are used to connect elements together. - * BaseLink is used to set link properties, and LinkLabel is used to render labels at specific positions along links. - * @group Components - * @experimental This feature is experimental and may change in the future. - * @example - * ```tsx - * import { Link } from '@joint/react'; - * - * function RenderLink({ id }) { - * return ( - * <> - * - * - * Label - * - * - * ); - * } - * ``` + * Link-related exports + * Note: BaseLink and LinkLabel have been removed in favor of useLinkPath() hook */ -export namespace Link { - /** - * BaseLink component sets link properties when rendering custom links. - * Must be used inside `renderLink` function. - * @experimental This feature is experimental and may change in the future. - * @group Components - * @category Link - */ - - export const { Base } = Component; - /** - * LinkLabel component renders content at a specific position along a link. - * Must be used inside `renderLink` function. - * @experimental This feature is experimental and may change in the future. - * @group Components - * @category Link - */ - export const { Label } = Component; -} -export { BaseLink } from './base-link'; -export { LinkLabel } from './link-label'; +// Arrow utilities remain useful for custom link rendering +export { LINK_ARROWS, getLinkArrow, type LinkArrowName, type LinkArrowMarker, type MarkerProps } from './link.arrows'; diff --git a/packages/joint-react/src/components/link/link-label.stories.tsx b/packages/joint-react/src/components/link/link-label.stories.tsx deleted file mode 100644 index 7d2b73a30..000000000 --- a/packages/joint-react/src/components/link/link-label.stories.tsx +++ /dev/null @@ -1,101 +0,0 @@ - -import type { Meta, StoryObj } from '@storybook/react'; -import '../../stories/examples/index.css'; -import { Link } from '@joint/react'; -import { getAPILink } from '../../stories/utils/get-api-documentation-link'; -import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story'; -import { SimpleRenderLinkDecorator } from 'storybook-config/decorators/with-simple-data'; - -export type Story = StoryObj; -const API_URL = getAPILink('Link.Label', 'variables'); - -const meta: Meta = { - title: 'Components/Link/Label', - component: Link.Label, - decorators: [SimpleRenderLinkDecorator], - tags: ['component'], - parameters: makeRootDocumentation({ - apiURL: API_URL, - description: ` -The **Link.Label** component renders content at a specific position along a link. It uses React portals to render children into the link label node. - -**Key Features:** -- Renders content at specific positions along links (start, middle, end, custom) -- Supports custom positioning with distance, offset, and angle -- Uses React portals for rendering -- Must be used inside renderLink context - `, - usage: ` -\`\`\`tsx -import { Link } from '@joint/react'; - -function RenderLink({ id }) { - return ( - <> - - - Label - - - ); -} -\`\`\` - `, - props: ` -- **position**: Position of the label along the link (required) - - **distance**: 0-1 (0 = start, 0.5 = middle, 1 = end) - - **offset**: number (perpendicular) or {x, y} (absolute) - - **angle**: rotation angle in degrees -- **children**: Content to render inside the label portal -- **attrs**: Label attributes -- **size**: Label size - `, - code: `import { Link } from '@joint/react'; - - - Label - - `, - }), -}; - -export default meta; - -function Component() { - const labelWidth = 100; - const labelHeight = 20; - // Center the label by offsetting by negative half-width and half-height - const offsetX = -labelWidth / 2; - const offsetY = -labelHeight / 2; - - return ( - <> - - -
- Start -
-
-
- - -
- Middle -
-
-
- - -
- End -
-
-
- - ); -} -export const Default = makeStory({ - component: Component, - apiURL: API_URL, - name: 'Label at middle', -}); diff --git a/packages/joint-react/src/components/link/link-label.tsx b/packages/joint-react/src/components/link/link-label.tsx deleted file mode 100644 index 09908334a..000000000 --- a/packages/joint-react/src/components/link/link-label.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import type { dia } from '@joint/core'; -import { memo, useId, useLayoutEffect, useMemo, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { useGraphStore } from '../../hooks/use-graph-store'; -import { useGraphInternalStoreSelector } from '../../hooks/use-graph-store-selector'; -import { useCellId, usePaperStoreContext } from '../../hooks'; - -interface LinkLabelWithId extends dia.Link.Label { - readonly labelId: string; -} - -export interface LinkLabelPosition extends dia.LinkView.LabelOptions { - /** - * Distance along the link (0-1 for relative, or absolute pixels with absoluteDistance: true). - * 0 = start, 0.5 = middle, 1 = end - */ - readonly distance?: number; - /** - * Offset from the link path. - * Can be a number (perpendicular offset) or an object with x and y (absolute offset). - */ - readonly offset?: number | { readonly x: number; readonly y: number }; - /** - * Rotation angle in degrees. - */ - readonly angle?: number; - /** - * Additional position arguments (e.g., absoluteDistance, reverseDistance, absoluteOffset). - */ - readonly args?: Record; -} - -export interface LinkLabelProps extends LinkLabelPosition { - /** - * Children to render inside the label portal. - */ - readonly children?: React.ReactNode; - /** - * Label attributes. - */ - readonly attrs?: dia.Link.Label['attrs']; - /** - * Label size. - */ - readonly size?: dia.Link.Label['size']; -} - -// eslint-disable-next-line jsdoc/require-jsdoc -function Component(props: LinkLabelProps) { - const { children, attrs, size, angle, args, distance, offset, ensureLegibility, keepGradient } = - props; - const linkId = useCellId(); - const graphStore = useGraphStore(); - const { graph } = graphStore; - const paperStore = usePaperStoreContext(); - const { paper, paperId } = paperStore; - const labelsRef = useRef([]); - const labelId = useId(); - - // Prepare label data during render (synchronous, runs before useLayoutEffect) - const labelData = useMemo(() => { - if (!paper) { - return null; - } - const position: dia.Link.LabelPosition = { - distance, - offset, - angle, - args: { - ...args, - ensureLegibility, - keepGradient, - }, - }; - - return { - position, - attrs, - size, - labelId, - }; - }, [paper, distance, offset, angle, args, ensureLegibility, keepGradient, attrs, size, labelId]); - - // Effect 1: Create label on mount, remove on unmount - // Only depends on paper and graph (stable references) - runs on mount/unmount - useLayoutEffect(() => { - if (!labelData) { - return; - } - - const link = graph.getCell(linkId); - if (!link) { - throw new Error(`Link with id ${linkId} not found`); - } - if (!link.isLink()) { - throw new Error(`Cell with id ${linkId} is not a link`); - } - - // Apply pre-computed label data (faster than computing in effect) - graphStore.setLinkLabel(linkId, labelId, labelData); - graphStore.flushPendingUpdates(); - labelsRef.current = link.labels(); - return () => { - // Remove label via graphStore for batching - graphStore.removeLinkLabel(linkId, labelId); - }; - }, [graph, graphStore, linkId, labelId, labelData]); // labelData is pre-computed, so effect is faster - - // Effect 2: Update label when props change - // Uses pre-computed labelData from useMemo (faster than computing in effect) - useLayoutEffect(() => { - if (!labelData) { - return; - } - - const link = graph.getCell(linkId); - if (!link) { - return; - } - if (!link.isLink()) { - return; - } - - const currentLabels = link.labels(); - const existingLabelIndex = currentLabels.findIndex( - (label) => (label as LinkLabelWithId).labelId === labelId - ); - - if (existingLabelIndex === -1) { - return; - } - - // Apply pre-computed label data (faster than computing in effect) - graphStore.setLinkLabel(linkId, labelId, labelData); - labelsRef.current = link.labels(); - }, [graph, graphStore, linkId, labelId, labelData]); // labelData is pre-computed, so effect is faster - - const portalNode = useGraphInternalStoreSelector((state) => { - // Read labels directly from the link model to ensure we have the latest state - const link = graph.getCell(linkId); - if (!link?.isLink()) { - return null; - } - const labels = link.labels(); - const labelIndex = labels.findIndex((l) => (l as LinkLabelWithId).labelId === labelId); - if (labelIndex === -1) { - return null; - } - const linkLabelId = paperStore.getLinkLabelId(linkId, labelIndex); - return state.papers[paperId]?.linksData?.[linkLabelId]; - }); - - // Component always mounts, useLayoutEffect runs immediately to add label to graph - // Portal rendering waits until portalNode is available (createPortal requires valid DOM node) - if (!portalNode) { - return null; - } - - return createPortal(children, portalNode); -} - -/** - * LinkLabel component renders content at a specific position along a link. - * Must be used inside `renderLink` function. - * @group Components - * @category Link - * @example - * ```tsx - * function RenderLink({ id }) { - * return ( - * <> - * - * - * Label - * - * - * ); - * } - * ``` - */ -export const LinkLabel = memo(Component); diff --git a/packages/joint-react/src/components/link/link-label.types.ts b/packages/joint-react/src/components/link/link-label.types.ts deleted file mode 100644 index a51d01e72..000000000 --- a/packages/joint-react/src/components/link/link-label.types.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { dia } from '@joint/core'; - -/** - * Position options for link labels. - * Similar to Joint.js label position system. - */ -export interface LinkLabelPosition { - /** - * Distance along the link (0-1 for relative, or absolute pixels with absoluteDistance: true). - * 0 = start, 0.5 = middle, 1 = end - */ - readonly distance?: number; - /** - * Offset from the link path. - * Can be a number (perpendicular offset) or an object with x and y (absolute offset). - */ - readonly offset?: number | { readonly x: number; readonly y: number }; - /** - * Rotation angle in degrees. - */ - readonly angle?: number; - /** - * Additional position arguments (e.g., absoluteDistance, reverseDistance, absoluteOffset). - */ - readonly args?: Record; -} - -/** - * Props for the LinkLabel component. - */ -export interface LinkLabelProps { - /** - * Position of the label along the link. - */ - readonly position: LinkLabelPosition; - /** - * Optional unique identifier for the label. - * If not provided, the label will be identified by its index in the labels array. - */ - readonly id?: string; - /** - * Children to render inside the label portal. - */ - readonly children?: React.ReactNode; - /** - * Label attributes. - */ - readonly attrs?: dia.Link.Label['attrs']; - /** - * Label size. - */ - readonly size?: dia.Link.Label['size']; -} diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx index b5a1b8715..a8eb9322b 100644 --- a/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider-controlled-mode.test.tsx @@ -6,29 +6,28 @@ import { dia } from '@joint/core'; import { useElements, useLinks, useGraph } from '../../../hooks'; import type { GraphElement } from '../../../types/element-types'; import type { GraphLink } from '../../../types/link-types'; -import { mapLinkFromGraph } from '../../../utils/cell/cell-utilities'; import { GraphProvider } from '../../graph/graph-provider'; describe('GraphProvider Controlled Mode', () => { describe('Basic useState integration', () => { it('should sync React state to store and graph on initial mount', async () => { - const initialElements = [ - { id: '1', width: 100, height: 100 }, - { id: '2', width: 200, height: 200 }, - ]; + const initialElements: Record = { + '1': { width: 100, height: 100 }, + '2': { width: 200, height: 200 }, + }; let elementCount = 0; let elementIds: string[] = []; function TestComponent() { - const elements = useElements((items) => items); + const elements = useElements((items) => Object.values(items)); elementCount = elements.length; - elementIds = elements.map((element) => String(element.id)); + elementIds = Object.keys(useElements((items) => items)); return null; } function ControlledGraph() { - const [elements, setElements] = useState(() => initialElements); + const [elements, setElements] = useState>(() => initialElements); return ( @@ -45,23 +44,25 @@ describe('GraphProvider Controlled Mode', () => { }); it('should update store when React state changes via useState', async () => { - const initialElements = [{ id: '1', width: 100, height: 100 }]; + const initialElements: Record = { + '1': { width: 100, height: 100 }, + }; let elementCount = 0; let elementIds: string[] = []; function TestComponent() { - const elements = useElements((items) => items); + const elements = useElements((items) => Object.values(items)); elementCount = elements.length; - elementIds = elements.map((element) => String(element.id)); + elementIds = Object.keys(useElements((items) => items)); return null; } - let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; + let setElementsExternal: ((elements: Record) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(() => initialElements); - setElementsExternal = setElements as (elements: GraphElement[]) => void; + const [elements, setElements] = useState>(() => initialElements); + setElementsExternal = setElements as (elements: Record) => void; return ( @@ -77,13 +78,11 @@ describe('GraphProvider Controlled Mode', () => { }); act(() => { - setElementsExternal?.( - [ - { id: '1', width: 100, height: 100 }, - { id: '2', width: 200, height: 200 }, - { id: '3', width: 300, height: 300 }, - ] - ); + setElementsExternal?.({ + '1': { width: 100, height: 100 }, + '2': { width: 200, height: 200 }, + '3': { width: 300, height: 300 }, + }); }); await waitFor(() => { @@ -93,31 +92,34 @@ describe('GraphProvider Controlled Mode', () => { }); it('should handle both elements and links in controlled mode', async () => { - const initialElements = [{ id: '1', width: 100, height: 100 }]; - const initialLink = new dia.Link({ - id: 'link1', + const initialElements: Record = { + '1': { width: 100, height: 100 }, + }; + const initialLink: GraphLink = { type: 'standard.Link', source: { id: '1' }, target: { id: '2' }, - }); + }; let elementCount = 0; let linkCount = 0; function TestComponent() { - elementCount = useElements((items) => items.length); - linkCount = useLinks((items) => items.length); + elementCount = useElements((items) => Object.keys(items).length); + linkCount = useLinks((items) => Object.keys(items).length); return null; } - let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; - let setLinksExternal: ((links: GraphLink[]) => void) | null = null; + let setElementsExternal: ((elements: Record) => void) | null = null; + let setLinksExternal: ((links: Record) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(() => initialElements); - const [links, setLinks] = useState(() => [mapLinkFromGraph(initialLink)]); - setElementsExternal = setElements as (elements: GraphElement[]) => void; - setLinksExternal = setLinks as (links: GraphLink[]) => void; + const [elements, setElements] = useState>(() => initialElements); + const [links, setLinks] = useState>(() => ({ + 'link1': initialLink, + })); + setElementsExternal = setElements as (elements: Record) => void; + setLinksExternal = setLinks as (links: Record) => void; return ( { // Update elements only act(() => { - setElementsExternal?.( - [ - { id: '1', width: 100, height: 100 }, - { id: '2', width: 200, height: 200 }, - ] - ); + setElementsExternal?.({ + '1': { width: 100, height: 100 }, + '2': { width: 200, height: 200 }, + }); }); await waitFor(() => { @@ -154,24 +154,18 @@ describe('GraphProvider Controlled Mode', () => { // Update links only act(() => { - setLinksExternal?.([ - mapLinkFromGraph( - new dia.Link({ - id: 'link1', - type: 'standard.Link', - source: { id: '1' }, - target: { id: '2' }, - }) - ), - mapLinkFromGraph( - new dia.Link({ - id: 'link2', - type: 'standard.Link', - source: { id: '2' }, - target: { id: '1' }, - }) - ), - ]); + setLinksExternal?.({ + 'link1': { + type: 'standard.Link', + source: { id: '1' }, + target: { id: '2' }, + }, + 'link2': { + type: 'standard.Link', + source: { id: '2' }, + target: { id: '1' }, + }, + }); }); await waitFor(() => { @@ -183,21 +177,23 @@ describe('GraphProvider Controlled Mode', () => { describe('Rapid consecutive updates', () => { it('should handle rapid consecutive state updates correctly', async () => { - const initialElements = [{ id: '1', width: 100, height: 100 }]; + const initialElements: Record = { + '1': { width: 100, height: 100 }, + }; let elementCount = 0; function TestComponent() { - const count = useElements((items) => items.length); + const count = useElements((items) => Object.keys(items).length); elementCount = count; return null; } - let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; + let setElementsExternal: ((elements: Record) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(() => initialElements); - setElementsExternal = setElements as (elements: GraphElement[]) => void; + const [elements, setElements] = useState>(() => initialElements); + setElementsExternal = setElements as (elements: Record) => void; return ( @@ -213,27 +209,21 @@ describe('GraphProvider Controlled Mode', () => { // Rapid consecutive updates act(() => { - setElementsExternal?.( - [ - { id: '1', width: 100, height: 100 }, - { id: '2', width: 200, height: 200 }, - ] - ); - setElementsExternal?.( - [ - { id: '1', width: 100, height: 100 }, - { id: '2', width: 200, height: 200 }, - { id: '3', width: 300, height: 300 }, - ] - ); - setElementsExternal?.( - [ - { id: '1', width: 100, height: 100 }, - { id: '2', width: 200, height: 200 }, - { id: '3', width: 300, height: 300 }, - { id: '4', width: 400, height: 400 }, - ] - ); + setElementsExternal?.({ + '1': { width: 100, height: 100 }, + '2': { width: 200, height: 200 }, + }); + setElementsExternal?.({ + '1': { width: 100, height: 100 }, + '2': { width: 200, height: 200 }, + '3': { width: 300, height: 300 }, + }); + setElementsExternal?.({ + '1': { width: 100, height: 100 }, + '2': { width: 200, height: 200 }, + '3': { width: 300, height: 300 }, + '4': { width: 400, height: 400 }, + }); }); await waitFor( @@ -245,20 +235,22 @@ describe('GraphProvider Controlled Mode', () => { }); it('should handle 10 rapid updates without losing state', async () => { - const initialElements = [{ id: '1', width: 100, height: 100 }]; + const initialElements: Record = { + '1': { width: 100, height: 100 }, + }; let elementCount = 0; function TestComponent() { - elementCount = useElements((items) => items.length); + elementCount = useElements((items) => Object.keys(items).length); return null; } - let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; + let setElementsExternal: ((elements: Record) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(() => initialElements); - setElementsExternal = setElements as (elements: GraphElement[]) => void; + const [elements, setElements] = useState>(() => initialElements); + setElementsExternal = setElements as (elements: Record) => void; return ( @@ -275,13 +267,14 @@ describe('GraphProvider Controlled Mode', () => { // 10 rapid updates act(() => { for (let index = 2; index <= 11; index++) { - setElementsExternal?.( - Array.from({ length: index }, (_, elementIndex) => ({ - id: String(elementIndex + 1), - width: 100 * (elementIndex + 1), - height: 100 * (elementIndex + 1), - })) - ); + const newElements: Record = {}; + for (let elementIndex = 1; elementIndex <= index; elementIndex++) { + newElements[String(elementIndex)] = { + width: 100 * elementIndex, + height: 100 * elementIndex, + }; + } + setElementsExternal?.(newElements); } }); @@ -296,31 +289,34 @@ describe('GraphProvider Controlled Mode', () => { describe('Concurrent updates', () => { it('should handle concurrent element and link updates', async () => { - const initialElements = [{ id: '1', width: 100, height: 100 }]; - const initialLink = new dia.Link({ - id: 'link1', + const initialElements: Record = { + '1': { width: 100, height: 100 }, + }; + const initialLink: GraphLink = { type: 'standard.Link', source: { id: '1' }, target: { id: '2' }, - }); + }; let elementCount = 0; let linkCount = 0; function TestComponent() { - elementCount = useElements((items) => items.length); - linkCount = useLinks((items) => items.length); + elementCount = useElements((items) => Object.keys(items).length); + linkCount = useLinks((items) => Object.keys(items).length); return null; } - let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; - let setLinksExternal: ((links: GraphLink[]) => void) | null = null; + let setElementsExternal: ((elements: Record) => void) | null = null; + let setLinksExternal: ((links: Record) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(() => initialElements); - const [links, setLinks] = useState(() => [mapLinkFromGraph(initialLink)]); - setElementsExternal = setElements as (elements: GraphElement[]) => void; - setLinksExternal = setLinks as (links: GraphLink[]) => void; + const [elements, setElements] = useState>(() => initialElements); + const [links, setLinks] = useState>(() => ({ + 'link1': initialLink, + })); + setElementsExternal = setElements as (elements: Record) => void; + setLinksExternal = setLinks as (links: Record) => void; return ( { // Concurrent updates act(() => { - setElementsExternal?.( - [ - { id: '1', width: 100, height: 100 }, - { id: '2', width: 200, height: 200 }, - ] - ); - setLinksExternal?.([ - mapLinkFromGraph( - new dia.Link({ - id: 'link1', - type: 'standard.Link', - source: { id: '1' }, - target: { id: '2' }, - }) - ), - mapLinkFromGraph( - new dia.Link({ - id: 'link2', - type: 'standard.Link', - source: { id: '2' }, - target: { id: '1' }, - }) - ), - ]); + setElementsExternal?.({ + '1': { width: 100, height: 100 }, + '2': { width: 200, height: 200 }, + }); + setLinksExternal?.({ + 'link1': { + type: 'standard.Link', + source: { id: '1' }, + target: { id: '2' }, + }, + 'link2': { + type: 'standard.Link', + source: { id: '2' }, + target: { id: '1' }, + }, + }); }); await waitFor( @@ -378,28 +366,32 @@ describe('GraphProvider Controlled Mode', () => { }); it('should handle multiple rapid updates with callbacks', async () => { - const initialElements = [{ id: '1', width: 100, height: 100 }]; + const initialElements: Record = { + '1': { width: 100, height: 100 }, + }; let elementCount = 0; function TestComponent() { - const count = useElements((items) => items.length); + const count = useElements((items) => Object.keys(items).length); elementCount = count; return null; } function ControlledGraph() { - const [elements, setElements] = useState(() => initialElements); + const [elements, setElements] = useState>(() => initialElements); const handleAddElement = useCallback(() => { - setElements((previous) => [ - ...previous, - { - id: String(previous.length + 1), - width: 100 * (previous.length + 1), - height: 100 * (previous.length + 1), - }, - ]); + setElements((previous) => { + const newId = String(Object.keys(previous).length + 1); + return { + ...previous, + [newId]: { + width: 100 * (Object.keys(previous).length + 1), + height: 100 * (Object.keys(previous).length + 1), + }, + }; + }); }, []); return ( @@ -437,10 +429,12 @@ describe('GraphProvider Controlled Mode', () => { describe('User interaction sync back to React state', () => { it('should sync graph changes back to React state in controlled mode', async () => { - const initialElements = [{ id: '1', width: 100, height: 100 }]; + const initialElements: Record = { + '1': { width: 100, height: 100 }, + }; - let reactStateElements: GraphElement[] = []; - let storeElements: GraphElement[] = []; + let reactStateElements: Record = {}; + let storeElements: Record = {}; function TestComponent() { storeElements = useElements((items) => items); @@ -448,7 +442,7 @@ describe('GraphProvider Controlled Mode', () => { } function ControlledGraph() { - const [elements, setElements] = useState(() => initialElements); + const [elements, setElements] = useState>(() => initialElements); reactStateElements = elements; return ( @@ -484,8 +478,8 @@ describe('GraphProvider Controlled Mode', () => { const { getByRole } = render(); await waitFor(() => { - expect(reactStateElements.length).toBe(1); - expect(storeElements.length).toBe(1); + expect(Object.keys(reactStateElements).length).toBe(1); + expect(Object.keys(storeElements).length).toBe(1); }); // Simulate user interaction @@ -496,22 +490,24 @@ describe('GraphProvider Controlled Mode', () => { // Graph change should sync back to React state await waitFor( () => { - expect(reactStateElements.length).toBe(2); - expect(storeElements.length).toBe(2); - expect(reactStateElements.some((element) => element.id === '2')).toBe(true); - expect(storeElements.some((element) => element.id === '2')).toBe(true); + expect(Object.keys(reactStateElements).length).toBe(2); + expect(Object.keys(storeElements).length).toBe(2); + expect(reactStateElements['2']).toBeDefined(); + expect(storeElements['2']).toBeDefined(); }, { timeout: 3000 } ); }); it('should handle element position changes from user interaction', async () => { - const initialElements = [{ id: '1', width: 100, height: 100, x: 0, y: 0 }]; + const initialElements: Record = { + '1': { width: 100, height: 100, x: 0, y: 0 }, + }; - let reactStateElements: GraphElement[] = []; + let reactStateElements: Record = {}; function ControlledGraph() { - const [elements, setElements] = useState(() => initialElements); + const [elements, setElements] = useState>(() => initialElements); reactStateElements = elements; return ( @@ -542,9 +538,9 @@ describe('GraphProvider Controlled Mode', () => { const { getByRole } = render(); await waitFor(() => { - expect(reactStateElements.length).toBe(1); - expect(reactStateElements[0]?.x).toBe(0); - expect(reactStateElements[0]?.y).toBe(0); + expect(Object.keys(reactStateElements).length).toBe(1); + expect(reactStateElements['1']?.x).toBe(0); + expect(reactStateElements['1']?.y).toBe(0); }); // Simulate user interaction @@ -555,9 +551,9 @@ describe('GraphProvider Controlled Mode', () => { // Graph change should sync back to React state await waitFor( () => { - expect(reactStateElements.length).toBe(1); - expect(reactStateElements[0]?.x).toBe(100); - expect(reactStateElements[0]?.y).toBe(100); + expect(Object.keys(reactStateElements).length).toBe(1); + expect(reactStateElements['1']?.x).toBe(100); + expect(reactStateElements['1']?.y).toBe(100); }, { timeout: 3000 } ); @@ -565,21 +561,23 @@ describe('GraphProvider Controlled Mode', () => { }); describe('Edge cases', () => { - it('should handle empty arrays correctly', async () => { - const initialElements = [{ id: '1', width: 100, height: 100 }]; + it('should handle empty records correctly', async () => { + const initialElements: Record = { + '1': { width: 100, height: 100 }, + }; let elementCount = 0; function TestComponent() { - elementCount = useElements((items) => items.length); + elementCount = useElements((items) => Object.keys(items).length); return null; } - let setElementsExternal: ((elements: GraphElement[]) => void) | null = null; + let setElementsExternal: ((elements: Record) => void) | null = null; function ControlledGraph() { - const [elements, setElements] = useState(() => initialElements); - setElementsExternal = setElements as (elements: GraphElement[]) => void; + const [elements, setElements] = useState>(() => initialElements); + setElementsExternal = setElements as (elements: Record) => void; return ( @@ -595,7 +593,7 @@ describe('GraphProvider Controlled Mode', () => { // Clear all elements act(() => { - setElementsExternal?.([]); + setElementsExternal?.({}); }); await waitFor(() => { @@ -604,12 +602,10 @@ describe('GraphProvider Controlled Mode', () => { // Add elements back act(() => { - setElementsExternal?.( - [ - { id: '1', width: 100, height: 100 }, - { id: '2', width: 200, height: 200 }, - ] - ); + setElementsExternal?.({ + '1': { width: 100, height: 100 }, + '2': { width: 200, height: 200 }, + }); }); await waitFor(() => { @@ -622,14 +618,14 @@ describe('GraphProvider Controlled Mode', () => { let linkCount = 0; function TestComponent() { - elementCount = useElements((items) => items.length); - linkCount = useLinks((items) => items.length); + elementCount = useElements((items) => Object.keys(items).length); + linkCount = useLinks((items) => Object.keys(items).length); return null; } function ControlledGraph() { - const [elements, setElements] = useState([]); - const [links, setLinks] = useState([]); + const [elements, setElements] = useState>({}); + const [links, setLinks] = useState>({}); return ( { let elementCount = 0; function TestComponent() { - elementCount = useElements((items) => items.length); + elementCount = useElements((items) => Object.keys(items).length); return null; } function ControlledGraph() { - const [elements, setElements] = useState([]); + const [elements, setElements] = useState>({}); return ( @@ -40,12 +39,12 @@ describe('GraphProvider Coverage Tests', () => { let linkCount = 0; function TestComponent() { - linkCount = useLinks((items) => items.length); + linkCount = useLinks((items) => Object.keys(items).length); return null; } function ControlledGraph() { - const [links, setLinks] = useState([]); + const [links, setLinks] = useState>({}); return ( @@ -61,21 +60,21 @@ describe('GraphProvider Coverage Tests', () => { }); it('should handle only elements controlled (not links)', async () => { - const initialElements = [ - { id: '1', width: 100, height: 100, type: 'ReactElement' }, - ]; + const initialElements: Record = { + '1': { width: 100, height: 100, type: 'ReactElement' }, + }; let elementCount = 0; let linkCount = 0; function TestComponent() { - elementCount = useElements((items) => items.length); - linkCount = useLinks((items) => items.length); + elementCount = useElements((items) => Object.keys(items).length); + linkCount = useLinks((items) => Object.keys(items).length); return null; } function ControlledGraph() { - const [elements, setElements] = useState(initialElements); + const [elements, setElements] = useState>(initialElements); return ( @@ -92,24 +91,25 @@ describe('GraphProvider Coverage Tests', () => { }); it('should handle only links controlled (not elements)', async () => { - const initialLink = new dia.Link({ - id: 'link1', + const initialLink: GraphLink = { type: 'standard.Link', source: { id: '1' }, target: { id: '2' }, - }); + }; let elementCount = 0; let linkCount = 0; function TestComponent() { - elementCount = useElements((items) => items.length); - linkCount = useLinks((items) => items.length); + elementCount = useElements((items) => Object.keys(items).length); + linkCount = useLinks((items) => Object.keys(items).length); return null; } function ControlledGraph() { - const [links, setLinks] = useState(() => [mapLinkFromGraph(initialLink)]); + const [links, setLinks] = useState>(() => ({ + 'link1': initialLink, + })); return ( @@ -137,20 +137,20 @@ describe('GraphProvider Coverage Tests', () => { describe('GraphProvider edge cases', () => { it('should handle unmeasured elements (width/height <= 1)', async () => { - const unmeasuredElements = [ - { id: '1', width: 0, height: 0, type: 'ReactElement' }, - { id: '2', width: 1, height: 1, type: 'ReactElement' }, - ]; + const unmeasuredElements: Record = { + '1': { width: 0, height: 0, type: 'ReactElement' }, + '2': { width: 1, height: 1, type: 'ReactElement' }, + }; let elementCount = 0; function TestComponent() { - elementCount = useElements((items) => items.length); + elementCount = useElements((items) => Object.keys(items).length); return null; } function ControlledGraph() { - const [elements, setElements] = useState(unmeasuredElements); + const [elements, setElements] = useState>(unmeasuredElements); return ( diff --git a/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx b/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx index 3f25333b2..e841c6f29 100644 --- a/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/graph-provider.test.tsx @@ -1,13 +1,12 @@ -/* eslint-disable react-perf/jsx-no-new-array-as-prop */ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ import React, { createRef, useState, useCallback } from 'react'; import { act, render, waitFor } from '@testing-library/react'; import { GraphStoreContext } from '../../../context'; -import { GraphStore } from '../../../store'; +import { GraphStore, DEFAULT_CELL_NAMESPACE } from '../../../store'; import { dia, shapes } from '@joint/core'; import { useElements, useLinks } from '../../../hooks'; import type { GraphElement } from '../../../types/element-types'; import type { GraphLink } from '../../../types/link-types'; -import { mapLinkFromGraph } from '../../../utils/cell/cell-utilities'; import { GraphProvider } from '../../graph/graph-provider'; import { Paper } from '../../paper/paper'; import type { RenderLink } from '../../paper/paper.types'; @@ -42,25 +41,24 @@ describe('graph', () => { }); it('should render graph provider with links and elements', async () => { - const elements = [ - { + const elements: Record = { + 'element1': { width: 100, height: 100, - id: 'element1', }, - ]; - const link = new dia.Link({ id: 'link1', type: 'standard.Link', source: { id: 'element1' } }); + }; + const link: GraphLink = { type: 'standard.Link', source: { id: 'element1' }, target: {} }; let linkCount = 0; let elementCount = 0; function TestComponent() { - linkCount = useElements((items) => items.length); + linkCount = useElements((items) => Object.keys(items).length); elementCount = useLinks((items) => { - return items.length; + return Object.keys(items).length; }); return null; } render( - + ); @@ -71,14 +69,14 @@ describe('graph', () => { }); }); it('should add elements and links after initial load and useElements and useLinks should catch them', async () => { - const graph = new dia.Graph(); + const graph = new dia.Graph({}, { cellNamespace: DEFAULT_CELL_NAMESPACE }); let linkCount = 0; let elementCount = 0; // eslint-disable-next-line sonarjs/no-identical-functions function TestComponent() { - linkCount = useElements((items) => items.length); + linkCount = useElements((items) => Object.keys(items).length); elementCount = useLinks((items) => { - return items.length; + return Object.keys(items).length; }); return null; } @@ -93,27 +91,32 @@ describe('graph', () => { expect(elementCount).toBe(0); }); - act(() => { + await act(async () => { graph.addCells([ new dia.Element({ id: 'element1', type: 'standard.Rectangle' }), new dia.Link({ id: 'link1', type: 'standard.Link', source: { id: 'element1' } }), ]); }); + // Allow multiple scheduler ticks to flush + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + await waitFor(() => { expect(linkCount).toBe(1); expect(elementCount).toBe(1); - }); + }, { timeout: 2000 }); }); it('should initialize with default elements', async () => { - const elements = [ - { width: 100, height: 100, id: 'element1' }, - { width: 200, height: 200, id: 'element2' }, - ]; + const elements: Record = { + 'element1': { width: 100, height: 100 }, + 'element2': { width: 200, height: 200 }, + }; let elementCount = 0; function TestComponent() { - elementCount = useElements((items) => items.length); + elementCount = useElements((items) => Object.keys(items).length); return null; } render( @@ -147,7 +150,7 @@ describe('graph', () => { const graph = new dia.Graph({}, { cellNamespace: shapes }); const cell = new dia.Element({ id: 'element1', type: 'standard.Rectangle' }); graph.addCell(cell); - let currentElements: GraphElement[] = []; + let currentElements: Record = {}; function Elements() { const elements = useElements(); currentElements = elements; @@ -163,7 +166,7 @@ describe('graph', () => { await waitFor(() => { expect(graph.getCells()).toHaveLength(1); - expect(currentElements).toHaveLength(1); + expect(Object.keys(currentElements)).toHaveLength(1); expect(graph.getCell('element1')).toBeDefined(); }); @@ -174,14 +177,14 @@ describe('graph', () => { await waitFor(() => { expect(graph.getCell('element2')).toBeDefined(); expect(graph.getCells()).toHaveLength(2); - expect(currentElements).toHaveLength(2); + expect(Object.keys(currentElements)).toHaveLength(2); }); // its external graph, so we do not destroy it unmount(); await waitFor(() => { expect(graph.getCells()).toHaveLength(2); - expect(currentElements).toHaveLength(2); + expect(Object.keys(currentElements)).toHaveLength(2); }); }); @@ -190,7 +193,7 @@ describe('graph', () => { const store = new GraphStore({ graph }); const cell = new dia.Element({ id: 'element1', type: 'standard.Rectangle' }); graph.addCell(cell); - let currentElements: GraphElement[] = []; + let currentElements: Record = {}; // eslint-disable-next-line sonarjs/no-identical-functions function Elements() { const elements = useElements(); @@ -209,7 +212,7 @@ describe('graph', () => { await waitFor(() => { expect(graph.getCells()).toHaveLength(1); - expect(currentElements).toHaveLength(1); + expect(Object.keys(currentElements)).toHaveLength(1); }); act(() => { @@ -219,39 +222,38 @@ describe('graph', () => { await waitFor(() => { expect(graph.getCell('element2')).toBeDefined(); expect(graph.getCells()).toHaveLength(2); - expect(currentElements).toHaveLength(2); + expect(Object.keys(currentElements)).toHaveLength(2); }); // its external graph, so we do not destroy it unmount(); await waitFor(() => { expect(graph.getCells()).toHaveLength(2); - expect(currentElements).toHaveLength(2); + expect(Object.keys(currentElements)).toHaveLength(2); }); }); it('should render graph provider with links and elements - with explicit react type', async () => { - const elements = [ - { + const elements: Record = { + 'element1': { width: 100, height: 100, - id: 'element1', type: 'ReactElement', }, - ]; - const link = new dia.Link({ id: 'link1', type: 'standard.Link', source: { id: 'element1' } }); + }; + const link: GraphLink = { type: 'standard.Link', source: { id: 'element1' }, target: {} }; let linkCount = 0; let elementCount = 0; // eslint-disable-next-line sonarjs/no-identical-functions function TestComponent() { - linkCount = useElements((items) => items.length); + linkCount = useElements((items) => Object.keys(items).length); elementCount = useLinks((items) => { - return items.length; + return Object.keys(items).length; }); return null; } render( - + ); @@ -263,39 +265,40 @@ describe('graph', () => { }); it('should update graph in controlled mode', async () => { - const initialElements = [ - { + const initialElements: Record = { + 'element1': { width: 100, height: 100, - id: 'element1', type: 'ReactElement', }, - ]; - const initialLink = new dia.Link({ - id: 'link1', + }; + const initialLink: GraphLink = { type: 'standard.Link', source: { id: 'element1' }, - }); + target: {}, + }; let linkCount = 0; let elementCount = 0; function TestComponent() { linkCount = useLinks((items) => { - return items.length; + return Object.keys(items).length; }); elementCount = useElements((items) => { - return items.length; + return Object.keys(items).length; }); return null; } - let setElementsOutside: ((elements: GraphElement[]) => void) | null = null; - let setLinksOutside: ((links: GraphLink[]) => void) | null = null; + let setElementsOutside: ((elements: Record) => void) | null = null; + let setLinksOutside: ((links: Record) => void) | null = null; function Graph() { - const [elements, setElements] = useState(() => initialElements); - const [links, setLinks] = useState(() => [mapLinkFromGraph(initialLink)]); - setElementsOutside = setElements as unknown as (elements: GraphElement[]) => void; - setLinksOutside = setLinks as unknown as (links: GraphLink[]) => void; + const [elements, setElements] = useState>(() => initialElements); + const [links, setLinks] = useState>(() => ({ + 'link1': initialLink, + })); + setElementsOutside = setElements as unknown as (elements: Record) => void; + setLinksOutside = setLinks as unknown as (links: Record) => void; return ( { }); act(() => { - setElementsOutside?.([ - { + setElementsOutside?.({ + 'element1': { width: 100, height: 100, - id: 'element1', type: 'ReactElement', }, - { + 'element2': { width: 10, height: 10, - id: 'element2', type: 'ReactElement', }, - ]); + }); }); await waitFor(() => { @@ -338,24 +339,18 @@ describe('graph', () => { // add link act(() => { - setLinksOutside?.([ - mapLinkFromGraph( - new dia.Link({ - id: 'link2', - type: 'standard.Link', - source: { id: 'element1' }, - target: { id: 'element2' }, - }) - ), - mapLinkFromGraph( - new dia.Link({ - id: 'link3', - type: 'standard.Link', - source: { id: 'element1' }, - target: { id: 'element2' }, - }) - ), - ]); + setLinksOutside?.({ + 'link2': { + type: 'standard.Link', + source: { id: 'element1' }, + target: { id: 'element2' }, + }, + 'link3': { + type: 'standard.Link', + source: { id: 'element1' }, + target: { id: 'element2' }, + }, + }); }); await waitFor(() => { @@ -373,47 +368,43 @@ describe('graph', () => { }); it('should pass correct link data to renderLink function', async () => { - const elements = [ - { - id: 'element-1', + const elements: Record = { + 'element-1': { x: 0, y: 0, width: 100, height: 100, }, - { - id: 'element-2', + 'element-2': { x: 200, y: 200, width: 100, height: 100, }, - ]; + }; - const links: GraphLink[] = [ - { - id: 'link-1', + const links: Record = { + 'link-1': { source: 'element-1', target: 'element-2', type: 'ReactLink', z: 1, }, - { - id: 'link-2', + 'link-2': { source: 'element-2', target: 'element-1', type: 'ReactLink', z: 2, customProperty: 'custom-value', }, - ]; + }; const receivedLinks: GraphLink[] = []; function TestComponent() { const renderLink: RenderLink = useCallback((link) => { receivedLinks.push(link); - return ; + return ; }, []); return ( @@ -437,64 +428,54 @@ describe('graph', () => { expect(receivedLinks.length).toBe(2); }); - // Verify first link data - const link1 = receivedLinks.find((link) => link.id === 'link-1'); + // Verify link data was passed (links no longer have id property) + const link1 = receivedLinks.find((link) => link.source === 'element-1' && link.target === 'element-2'); expect(link1).toBeDefined(); - expect(link1?.id).toBe('link-1'); - expect(link1?.source).toBe('element-1'); - expect(link1?.target).toBe('element-2'); expect(link1?.type).toBe('ReactLink'); expect(link1?.z).toBe(1); - // Verify second link data - const link2 = receivedLinks.find((link) => link.id === 'link-2'); + const link2 = receivedLinks.find((link) => link.source === 'element-2' && link.target === 'element-1'); expect(link2).toBeDefined(); - expect(link2?.id).toBe('link-2'); - expect(link2?.source).toBe('element-2'); - expect(link2?.target).toBe('element-1'); expect(link2?.type).toBe('ReactLink'); expect(link2?.z).toBe(2); expect(link2?.customProperty).toBe('custom-value'); }); it('should pass updated link data to renderLink when links change', async () => { - const elements = [ - { - id: 'element-1', + const elements: Record = { + 'element-1': { x: 0, y: 0, width: 100, height: 100, }, - { - id: 'element-2', + 'element-2': { x: 200, y: 200, width: 100, height: 100, }, - ]; + }; - const initialLinks: GraphLink[] = [ - { - id: 'link-1', + const initialLinks: Record = { + 'link-1': { source: 'element-1', target: 'element-2', type: 'ReactLink', }, - ]; + }; const receivedLinks: GraphLink[] = []; - let setLinksExternal: ((links: GraphLink[]) => void) | null = null; + let setLinksExternal: ((links: Record) => void) | null = null; function ControlledGraph() { - const [links, setLinks] = useState(() => initialLinks); - setLinksExternal = setLinks as unknown as (links: GraphLink[]) => void; + const [links, setLinks] = useState>(() => initialLinks); + setLinksExternal = setLinks as unknown as (links: Record) => void; const renderLink: RenderLink = useCallback((link) => { receivedLinks.push(link); - return ; + return ; }, []); return ( @@ -516,36 +497,34 @@ describe('graph', () => { expect(receivedLinks.length).toBeGreaterThanOrEqual(1); }); - const initialLink = receivedLinks.find((link) => link.id === 'link-1'); + // Verify initial link was received + const initialLink = receivedLinks.find((link) => link.source === 'element-1' && link.target === 'element-2'); expect(initialLink).toBeDefined(); - expect(initialLink?.source).toBe('element-1'); - expect(initialLink?.target).toBe('element-2'); + expect(initialLink?.type).toBe('ReactLink'); // Clear received links to track new ones receivedLinks.length = 0; // Update links act(() => { - setLinksExternal?.([ - { - id: 'link-2', + setLinksExternal?.({ + 'link-2': { source: 'element-2', target: 'element-1', type: 'ReactLink', customProperty: 'updated-value', }, - ]); + }); }); await waitFor(() => { expect(receivedLinks.length).toBeGreaterThanOrEqual(1); }); - const updatedLink = receivedLinks.find((link) => link.id === 'link-2'); + // Verify updated link was received + const updatedLink = receivedLinks.find((link) => link.source === 'element-2' && link.target === 'element-1'); expect(updatedLink).toBeDefined(); - expect(updatedLink?.id).toBe('link-2'); - expect(updatedLink?.source).toBe('element-2'); - expect(updatedLink?.target).toBe('element-1'); + expect(updatedLink?.type).toBe('ReactLink'); expect(updatedLink?.customProperty).toBe('updated-value'); }); }); diff --git a/packages/joint-react/src/components/paper/__tests__/paper-html-overlay-links.test.tsx b/packages/joint-react/src/components/paper/__tests__/paper-html-overlay-links.test.tsx index 8d0fb5af3..88c2f7d12 100644 --- a/packages/joint-react/src/components/paper/__tests__/paper-html-overlay-links.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/paper-html-overlay-links.test.tsx @@ -1,24 +1,26 @@ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ -import { render, waitFor, screen } from '@testing-library/react'; +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +import { render, waitFor, screen, act } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { GraphProvider, Paper, type GraphElement, type GraphLink } from '../../../index'; +import { GraphProvider, Paper, useCellActions, type GraphElement, type GraphLink } from '../../../index'; +import { useCallback } from 'react'; interface TestElement extends GraphElement { label: string; } -const elements: TestElement[] = [ - { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, - { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]; +const elements: Record = { + '1': { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + '2': { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, +}; -const links: GraphLink[] = [ - { +const links: Record = { + 'link-1': { id: 'link-1', source: '1', target: '2', }, -]; +}; describe('Paper with useHTMLOverlay and links', () => { it('renders links when useHTMLOverlay is enabled', async () => { @@ -38,15 +40,24 @@ describe('Paper with useHTMLOverlay and links', () => { }); await waitFor(() => { - // The link should be present with a path element - const linkPath = container.querySelector('.joint-type-reactlink path[joint-selector="line"]'); - expect(linkPath).toBeInTheDocument(); - - const d = linkPath?.getAttribute('d'); - // Link should have a path (jsdom may not fully compute SVG geometry, - // so we just verify the path exists and isn't completely empty) - expect(d).toBeTruthy(); + // ReactLink has empty markup (no SVG content), so we just check that + // the link view container exists in the DOM + // The link view is added by JointJS but ReactLink doesn't render SVG paths + const linkView = container.querySelector('.joint-type-reactlink'); + expect(linkView).toBeInTheDocument(); }); + + // Also verify initial links have valid paths + await waitFor( + () => { + const linkPath = container.querySelector('.joint-link path[joint-selector="line"]'); + expect(linkPath).toBeInTheDocument(); + const pathD = linkPath?.getAttribute('d'); + expect(pathD).toBeTruthy(); + expect(pathD?.startsWith('M')).toBe(true); + }, + { timeout: 2000 } + ); }); it('renders placeholder rect in SVG element when useHTMLOverlay is enabled', async () => { @@ -77,4 +88,121 @@ describe('Paper with useHTMLOverlay and links', () => { } }); }); + + it('renders link with valid path when adding a new link dynamically via useCellActions', async () => { + let setLinkAction: (() => void) | null = null; + + function AddLinkButton() { + const { set } = useCellActions(); + setLinkAction = useCallback(() => { + set('new-link', { + source: '1', + target: '2', + attrs: { + line: { stroke: '#FF0000' }, + }, + }); + }, [set]); + return null; + } + + const initialElements: Record = { + '1': { id: '1', label: 'Element1', x: 100, y: 0, width: 100, height: 50 }, + '2': { id: '2', label: 'Element2', x: 100, y: 200, width: 100, height: 50 }, + }; + + const { container } = render( + + + useHTMLOverlay + renderElement={({ label }) =>
{label}
} + /> + +
+ ); + + // Wait for elements to render + await waitFor(() => { + expect(screen.getByText('Element1')).toBeInTheDocument(); + expect(screen.getByText('Element2')).toBeInTheDocument(); + }); + + // Initially there should be no links + await waitFor(() => { + const linkViews = container.querySelectorAll('.joint-link'); + expect(linkViews.length).toBe(0); + }); + + // Add a new link dynamically + await act(async () => { + setLinkAction?.(); + }); + + // Wait for the link to be rendered + await waitFor( + () => { + const linkViews = container.querySelectorAll('.joint-link'); + expect(linkViews.length).toBe(1); + }, + { timeout: 2000 } + ); + + // Verify the link has a valid path (not empty) + await waitFor( + () => { + const linkPath = container.querySelector('.joint-link path[joint-selector="line"]'); + expect(linkPath).toBeInTheDocument(); + const pathD = linkPath?.getAttribute('d'); + // The path should have a valid d attribute (not empty or null) + expect(pathD).toBeTruthy(); + expect(pathD?.length).toBeGreaterThan(0); + // A valid path should start with 'M' (moveto command) + expect(pathD?.startsWith('M')).toBe(true); + }, + { timeout: 2000 } + ); + }); + + it('removes link correctly when using useCellActions.remove', async () => { + let removeLinkAction: (() => void) | null = null; + + function RemoveLinkButton() { + const { remove } = useCellActions(); + removeLinkAction = useCallback(() => { + remove('link-1'); + }, [remove]); + return null; + } + + const { container } = render( + + + useHTMLOverlay + renderElement={({ label }) =>
{label}
} + /> + +
+ ); + + // Wait for elements and link to render + await waitFor(() => { + expect(screen.getByText('Hello')).toBeInTheDocument(); + const linkViews = container.querySelectorAll('.joint-link'); + expect(linkViews.length).toBe(1); + }); + + // Remove the link + await act(async () => { + removeLinkAction?.(); + }); + + // Wait for the link to be removed + await waitFor( + () => { + const linkViews = container.querySelectorAll('.joint-link'); + expect(linkViews.length).toBe(0); + }, + { timeout: 2000 } + ); + }); }); diff --git a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx index 841876a72..440fc3b6a 100644 --- a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable react-perf/jsx-no-new-array-as-prop */ + /* eslint-disable @eslint-react/web-api/no-leaked-timeout */ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ /* eslint-disable sonarjs/no-nested-functions */ @@ -9,17 +9,26 @@ import React from 'react'; import { useNodeSize } from '../../../hooks/use-node-size'; import { act, useEffect, useRef, useState, type RefObject } from 'react'; import type { PaperStore } from '../../../store'; -import { useGraph, usePaperStoreContext } from '../../../hooks'; +import { useGraph, usePaperStoreContext, useCellId } from '../../../hooks'; import type { GraphElement } from '../../../types/element-types'; import { GraphProvider } from '../../graph/graph-provider'; import { Paper } from '../paper'; -const elements = [ - { id: '1', label: 'Node 1', width: 10, height: 10 }, - { id: '2', label: 'Node 2', width: 10, height: 10 }, -]; +const elements: Record = { + '1': { label: 'Node 1', width: 10, height: 10 }, + '2': { label: 'Node 2', width: 10, height: 10 }, +}; + +function TestNode({ width, height }: Readonly<{ width?: number; height?: number }>) { + const id = useCellId(); + return ( +
+ {id} +
+ ); +} -type Element = (typeof elements)[number]; +type Element = (typeof elements)[keyof typeof elements]; const WIDTH = 200; // we need to mock `new ResizeObserver`, to return the size width 50 and height 50 for test purposes @@ -100,10 +109,10 @@ describe('Paper Component', () => { it('calls onElementsSizeChange when element sizes change', async () => { const onElementsSizeChangeMock = jest.fn(); - const updatedElements = [ - { id: '1', label: 'Node 1', width: 100, height: 50 }, - { id: '2', label: 'Node 2', width: 150, height: 75 }, - ]; + const updatedElements: Record = { + '1': { id: '1', label: 'Node 1', width: 100, height: 50 }, + '2': { id: '2', label: 'Node 2', width: 150, height: 75 }, + }; const { rerender } = render( @@ -323,10 +332,10 @@ describe('Paper Component', () => { it('should set elements and positions via react state, when change it via paper api', async () => { // Create elements with initial x/y so they can be synced back - const elementsWithPosition = [ - { id: '1', label: 'Node 1', x: 0, y: 0, width: 10, height: 10 }, - { id: '2', label: 'Node 2', x: 0, y: 0, width: 10, height: 10 }, - ]; + const elementsWithPosition: Record = { + '1': { id: '1', label: 'Node 1', x: 0, y: 0, width: 10, height: 10 }, + '2': { id: '2', label: 'Node 2', x: 0, y: 0, width: 10, height: 10 }, + }; // eslint-disable-next-line unicorn/consistent-function-scoping function UpdatePosition() { const graph = useGraph(); @@ -338,10 +347,10 @@ describe('Paper Component', () => { }, [graph]); return null; } - let currentOutsideElements: Element[] = []; + let currentOutsideElements: Record = {}; function Content() { - const [currentElements, setCurrentElements] = useState(elementsWithPosition); - currentOutsideElements = currentElements as Element[]; + const [currentElements, setCurrentElements] = useState>(elementsWithPosition); + currentOutsideElements = currentElements as Record; return ( renderElement={() =>
Test
} /> @@ -351,7 +360,7 @@ describe('Paper Component', () => { } render(); await waitFor(() => { - const element1 = currentOutsideElements.find((element) => element.id === '1'); + const element1 = currentOutsideElements['1']; expect(element1).toBeDefined(); // @ts-expect-error we know it's element expect(element1.x).toBe(100); @@ -361,27 +370,25 @@ describe('Paper Component', () => { }); it('should update elements via react state, and then reflect the changes in the paper', async () => { function Content() { - const [currentElements, setCurrentElements] = useState(elements); + const [currentElements, setCurrentElements] = useState>(elements); return ( - renderElement={({ width, height, id }) => { - return ( -
- {id} -
- ); + renderElement={({ width, height }) => { + return ; }} /> + + ); +} + +function getLinkEndpointId(endpoint: GraphLink['source']): string { + if (typeof endpoint === 'string') return endpoint; + if (typeof endpoint === 'object' && 'id' in endpoint) { + return String(endpoint.id); + } + return 'unknown'; +} + +interface LinkControlsProps { + readonly id: string; + readonly link: GraphLink; +} + +function LinkControls({ id, link }: Readonly) { + const { set, remove } = useCellActions(); + const sourceId = getLinkEndpointId(link.source); + const targetId = getLinkEndpointId(link.target); + + return ( +
+
+ {sourceId} β†’ {targetId} +
+ + {/* Stroke Color */} +
+ + + set(id, (previous) => ({ + ...previous, + attrs: { ...previous.attrs, line: { ...previous.attrs?.line, stroke: event.target.value } }, + })) + } + style={{ width: 36, height: 28, border: 'none', cursor: 'pointer', borderRadius: 6, padding: 0 }} + /> +
+ + {/* Remove */} + +
+ ); +} + +function AddElementForm() { + const { set } = useCellActions(); + const elements = useElements(); + const [label, setLabel] = useState(''); + + const handleAdd = () => { + if (!label.trim()) return; + + const existingIds = Object.keys(elements).map(Number).filter((numberValue) => !Number.isNaN(numberValue)); + const newId = String(Math.max(0, ...existingIds) + 1); + + // eslint-disable-next-line sonarjs/pseudo-random -- Random position for demo purposes + const randomX = 50 + Math.random() * 200; + // eslint-disable-next-line sonarjs/pseudo-random -- Random position for demo purposes + const randomY = 50 + Math.random() * 150; + + set(newId, { + label: label.trim(), + color: PRIMARY, + x: randomX, + y: randomY, + width: 120, + height: 60, + }); + setLabel(''); + }; + + return ( +
+
Add Node
+
+ setLabel(event.target.value)} + placeholder="Label..." + style={{ + flex: 1, + padding: '8px 12px', + border: '1px solid rgba(0, 0, 0, 0.15)', + borderRadius: 8, + fontSize: 12, + backgroundColor: 'rgba(255, 255, 255, 0.9)', + color: '#1f2937', + outline: 'none', + }} + onKeyDown={(event) => event.key === 'Enter' && handleAdd()} + /> + +
+
+ ); +} + +function AddLinkForm() { + const { set } = useCellActions(); + const elements = useElements(); + const [source, setSource] = useState(''); + const [target, setTarget] = useState(''); + + const elementIds = Object.keys(elements); + + const selectStyle = { + flex: 1, + padding: '8px 10px', + border: '1px solid rgba(0, 0, 0, 0.15)', + borderRadius: 8, + fontSize: 12, + backgroundColor: 'rgba(255, 255, 255, 0.9)', + color: '#1f2937', + outline: 'none', + cursor: 'pointer', + }; + + const handleAdd = () => { + if (!source || !target || source === target) return; + + const newId = `link-${source}-${target}-${Date.now()}`; + set(newId, { + source, + target, + attrs: { line: { stroke: LIGHT } }, + }); + setSource(''); + setTarget(''); + }; + + return ( +
+
Add Link
+
+ + + +
+
+ ); +} + +// --- Main Component --- + +function Main() { + const elements = useElements(); + const links = useLinks(); + + return ( +
+ {/* Canvas */} + + + {/* Control Panel - Glassmorphism Style */} +
+
+ Cell Actions +
+ + {/* Add forms */} + + + + {/* Element Controls */} +
+ Nodes ({Object.keys(elements).length}) +
+ {Object.entries(elements).map(([id, element]) => ( + + ))} + + {/* Link Controls */} +
+ Links ({Object.keys(links).length}) +
+ {Object.entries(links).map(([id, link]) => ( + + ))} +
+
+ ); +} + +export default function App() { + return ( + +
+ + ); +} diff --git a/packages/joint-react/src/stories/examples/with-cell-actions/story.tsx b/packages/joint-react/src/stories/examples/with-cell-actions/story.tsx new file mode 100644 index 000000000..67dc1e97c --- /dev/null +++ b/packages/joint-react/src/stories/examples/with-cell-actions/story.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import '../index.css'; +import Code from './code'; + +export type Story = StoryObj; + +import { makeRootDocumentation } from '../../utils/make-story'; + +import CodeRaw from './code?raw'; + +export default { + title: 'Examples/Cell Actions', + tags: ['example'], + component: Code, + parameters: makeRootDocumentation({ + code: CodeRaw, + }), +} satisfies Meta; + +export const Default: Story = {}; diff --git a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx index b169d79bc..01fcd0f30 100644 --- a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx +++ b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links-classname.tsx @@ -12,14 +12,13 @@ import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; import './code-with-create-links-classname.css'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -const initialElements = [ - { id: '1', label: 'Node 1', x: 100, y: 0 }, - { id: '2', label: 'Node 2', x: 100, y: 200 }, -]; +const initialElements: Record = { + '1': { label: 'Node 1', x: 100, y: 0 }, + '2': { label: 'Node 2', x: 100, y: 200 }, +}; -const initialEdges: GraphLink[] = [ - { - id: 'e1-2', +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -29,9 +28,9 @@ const initialEdges: GraphLink[] = [ }, }, }, -]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; function Main() { const renderElement: RenderElement = useCallback( diff --git a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx index 3950c8eec..ea448e607 100644 --- a/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx +++ b/packages/joint-react/src/stories/examples/with-custom-link/code-with-create-links.tsx @@ -10,14 +10,13 @@ import { import { useCallback } from 'react'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; -const initialElements = [ - { id: '1', label: 'Node 1', x: 100, y: 0 }, - { id: '2', label: 'Node 2', x: 100, y: 200 }, -]; +const initialElements: Record = { + '1': { label: 'Node 1', x: 100, y: 0 }, + '2': { label: 'Node 2', x: 100, y: 200 }, +}; -const initialEdges: GraphLink[] = [ - { - id: 'e1-2', +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -28,9 +27,9 @@ const initialEdges: GraphLink[] = [ }, }, }, -]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; function Main() { const renderElement: RenderElement = useCallback( diff --git a/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx b/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx index 748a35df4..c2c955bbb 100644 --- a/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx +++ b/packages/joint-react/src/stories/examples/with-custom-link/code-with-dia-links.tsx @@ -7,10 +7,10 @@ import { useCallback } from 'react'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; import { Paper } from '../../../components/paper/paper'; -const initialElements = [ - { id: '1', label: 'Node 1', x: 100, y: 0 }, - { id: '2', label: 'Node 2', x: 100, y: 200 }, -]; +const initialElements: Record = { + '1': { label: 'Node 1', x: 100, y: 0 }, + '2': { label: 'Node 2', x: 100, y: 200 }, +}; class LinkModel extends shapes.standard.Link { defaults() { @@ -27,7 +27,7 @@ class LinkModel extends shapes.standard.Link { } } -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; function Main() { const renderElement: RenderElement = useCallback( @@ -47,15 +47,14 @@ function Main() { ); } -const links = [ - { +const links: Record = { + '1123': { source: '1', target: '2', type: 'LinkModel', - id: '1123', attrs: { line: { stroke: PRIMARY } }, }, -]; +}; export default function App(props: Readonly) { return ( diff --git a/packages/joint-react/src/stories/examples/with-highlighter/code.tsx b/packages/joint-react/src/stories/examples/with-highlighter/code.tsx index 2452634ac..a953c415c 100644 --- a/packages/joint-react/src/stories/examples/with-highlighter/code.tsx +++ b/packages/joint-react/src/stories/examples/with-highlighter/code.tsx @@ -5,28 +5,25 @@ import '../index.css'; import { useState } from 'react'; import { PAPER_CLASSNAME, PRIMARY, SECONDARY } from 'storybook-config/theme'; -const initialElements = [ - { - id: '1', +const initialElements: Record = { + '1': { label: 'Node 1', x: 100, y: 50, width: 125, height: 25, }, - { - id: '2', + '2': { label: 'Node 2', x: 100, y: 200, width: 120, height: 25, }, -] satisfies GraphElement[]; +}; -const initialEdges = [ - { - id: 'e1-2', +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -35,11 +32,11 @@ const initialEdges = [ }, }, }, -] satisfies GraphLink[]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; -function RenderItemWithChildren({ height, width, label }: Readonly) { +function RenderItemWithChildren({ height = 0, width = 0, label }: Readonly) { const [isHighlighted, setIsHighlighted] = useState(false); return ( = { + '1': { label: 'Node 1', x: 100, y: 0 }, + '2': { label: 'Node 2', x: 100, y: 200 }, + '3': { label: 'Node 3', x: 200, y: 100 }, + '4': { label: 'Node 4', x: 0, y: 100 }, +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; -function ResizableNode({ id, label }: Readonly) { +function ResizableNode({ label }: Readonly) { const nodeRef = useRef(null); const graph = useGraph(); + const id = useCellId(); const element = graph.getCell(id) as dia.Element; const isIntersected = useElements(() => { @@ -44,7 +45,7 @@ function Main() { export default function App() { return ( - +
); diff --git a/packages/joint-react/src/stories/examples/with-link-tools/code.tsx b/packages/joint-react/src/stories/examples/with-link-tools/code.tsx index 899cb4ad4..1887d281c 100644 --- a/packages/joint-react/src/stories/examples/with-link-tools/code.tsx +++ b/packages/joint-react/src/stories/examples/with-link-tools/code.tsx @@ -6,9 +6,8 @@ import { GraphProvider, jsx, Paper, type RenderElement } from '@joint/react'; import { useCallback } from 'react'; import { PRIMARY, BG, SECONDARY, PAPER_CLASSNAME } from 'storybook-config/theme'; -const initialEdges = [ - { - id: 'e1-2', +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -18,12 +17,12 @@ const initialEdges = [ }, }, }, -]; +}; -const initialElements = [ - { id: '1', label: 'Node 1', x: 100, y: 10, width: 120, height: 30 }, - { id: '2', label: 'Node 2', x: 100, y: 200, width: 120, height: 30 }, -]; +const initialElements: Record = { + '1': { label: 'Node 1', x: 100, y: 10, width: 120, height: 30 }, + '2': { label: 'Node 2', x: 100, y: 200, width: 120, height: 30 }, +}; // 1) creating link tools const verticesTool = new linkTools.Vertices({ @@ -66,7 +65,7 @@ const toolsView = new dia.ToolsView({ tools: [boundaryTool, verticesTool, infoButton], }); -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; function RectElement({ width, height }: Readonly) { return ( diff --git a/packages/joint-react/src/stories/examples/with-list-node/code.tsx b/packages/joint-react/src/stories/examples/with-list-node/code.tsx index 03b5d02e8..67d6296e8 100644 --- a/packages/joint-react/src/stories/examples/with-list-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-list-node/code.tsx @@ -3,17 +3,16 @@ import '../index.css'; import React, { useCallback, useRef, type PropsWithChildren } from 'react'; -import { GraphProvider, Paper, useNodeSize, type OnTransformElement } from '@joint/react'; +import { GraphProvider, Paper, useNodeSize, type OnTransformElement, useCellId } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = [ - { id: '1', label: 'Node 1', inputs: [] as string[], x: 100, y: 0 }, - { id: '2', label: 'Node 2', inputs: [] as string[], x: 500, y: 200 }, -]; -const initialEdges = [ - { - id: 'e1-2', +const initialElements: Record = { + '1': { label: 'Node 1', inputs: [] as string[], x: 100, y: 0 }, + '2': { label: 'Node 2', inputs: [] as string[], x: 500, y: 200 }, +}; +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -22,11 +21,12 @@ const initialEdges = [ }, }, }, -]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; -function ListElement({ id, children, inputs }: PropsWithChildren) { +function ListElement({ children, inputs }: PropsWithChildren) { + const id = useCellId(); const padding = 10; const headerHeight = 50; const elementRef = useRef(null); diff --git a/packages/joint-react/src/stories/examples/with-minimap/code.tsx b/packages/joint-react/src/stories/examples/with-minimap/code.tsx index 460656916..aa16ae7c3 100644 --- a/packages/joint-react/src/stories/examples/with-minimap/code.tsx +++ b/packages/joint-react/src/stories/examples/with-minimap/code.tsx @@ -4,13 +4,12 @@ import { useCallback, useRef } from 'react'; import { GraphProvider, Paper, useNodeSize, type RenderElement } from '@joint/react'; import { PRIMARY, SECONDARY, LIGHT, PAPER_CLASSNAME } from 'storybook-config/theme'; -const initialElements = [ - { id: '1', label: 'Node 1', color: PRIMARY, x: 100, y: 10, width: 100, height: 50 }, - { id: '2', label: 'Node 2', color: SECONDARY, x: 100, y: 200, width: 100, height: 50 }, -]; -const initialEdges = [ - { - id: 'e1-2', +const initialElements: Record = { + '1': { label: 'Node 1', color: PRIMARY, x: 100, y: 10, width: 100, height: 50 }, + '2': { label: 'Node 2', color: SECONDARY, x: 100, y: 200, width: 100, height: 50 }, +}; +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -19,9 +18,9 @@ const initialEdges = [ }, }, }, -]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; function MiniMap() { const renderElement: RenderElement = useCallback( diff --git a/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx b/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx index 38bb0c9f1..28a2514d2 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code-add-remove-node.tsx @@ -14,15 +14,14 @@ import { useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = [ - { id: '1', label: 'Node 1', color: '#ffffff', x: 40, y: 70 }, - { id: '2', label: 'Node 2', color: '#ffffff', x: 170, y: 120 }, - { id: '3', label: 'Node 2', color: '#ffffff', x: 30, y: 180 }, -]; +const initialElements: Record = { + '1': { label: 'Node 1', color: '#ffffff', x: 40, y: 70, width: 120, height: 80 }, + '2': { label: 'Node 2', color: '#ffffff', x: 170, y: 120, width: 120, height: 80 }, + '3': { label: 'Node 2', color: '#ffffff', x: 30, y: 180, width: 120, height: 80 }, +}; -const initialEdges: GraphLink[] = [ - { - id: 'e1-1', +const initialEdges: Record = { + 'e1-1': { source: '1', target: '2', attrs: { @@ -31,11 +30,15 @@ const initialEdges: GraphLink[] = [ }, }, }, -]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; -function ElementInput({ id, label }: Readonly) { +interface ElementInputProps extends BaseElementWithData { + readonly id: string; +} + +function ElementInput({ id, label }: Readonly) { const { set } = useCellActions(); return (
- {elements.map((item) => { - return ; + {Object.entries(elements).map(([id, item]) => { + return ; })}
diff --git a/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx b/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx index ee0fb6e99..405e5e34e 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx @@ -6,13 +6,13 @@ import { PRIMARY, LIGHT, PAPER_CLASSNAME } from 'storybook-config/theme'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = [ - { id: '1', label: 'Node 1', color: PRIMARY, x: 100, y: 0 }, - { id: '2', label: 'Node 2', color: PRIMARY, x: 100, y: 200 }, -]; +const initialElements: Record = { + '1': { id: '1', label: 'Node 1', color: PRIMARY, x: 100, y: 0, width: 100, height: 50 }, + '2': { id: '2', label: 'Node 2', color: PRIMARY, x: 100, y: 200, width: 100, height: 50 }, +}; -const initialEdges: GraphLink[] = [ - { +const initialEdges: Record = { + 'e1-2': { id: 'e1-2', source: '1', target: '2', @@ -22,9 +22,9 @@ const initialEdges: GraphLink[] = [ }, }, }, -]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; function RenderElement({ color, id }: Readonly) { const { set } = useCellActions(); diff --git a/packages/joint-react/src/stories/examples/with-node-update/code-with-svg.tsx b/packages/joint-react/src/stories/examples/with-node-update/code-with-svg.tsx index a907bb4f0..f3ecb7bc2 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code-with-svg.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code-with-svg.tsx @@ -5,14 +5,13 @@ import '../index.css'; import { LIGHT, PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = [ - { id: '1', color: PRIMARY, x: 100, y: 0, width: 130, height: 35 }, - { id: '2', color: PRIMARY, x: 100, y: 200, width: 130, height: 35 }, -]; +const initialElements: Record = { + '1': { color: PRIMARY, x: 100, y: 0, width: 130, height: 35 }, + '2': { color: PRIMARY, x: 100, y: 200, width: 130, height: 35 }, +}; -const initialEdges: GraphLink[] = [ - { - id: 'e1-2', +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -21,11 +20,15 @@ const initialEdges: GraphLink[] = [ }, }, }, -]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; -function ElementInput({ id, color }: Readonly) { +interface ElementInputProps extends BaseElementWithData { + readonly id: string; +} + +function ElementInput({ id, color }: Readonly) { const { set } = useCellActions(); return (
- {elements.map((item) => { - return ; + {Object.entries(elements).map(([id, item]) => { + return ; })}
diff --git a/packages/joint-react/src/stories/examples/with-node-update/code.tsx b/packages/joint-react/src/stories/examples/with-node-update/code.tsx index 6ef26c58d..c55ec7519 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code.tsx @@ -6,13 +6,13 @@ import { useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = [ - { id: '1', label: 'Node 1', color: '#ffffff', x: 100, y: 0 }, - { id: '2', label: 'Node 2', color: '#ffffff', x: 100, y: 200 }, -]; +const initialElements: Record = { + '1': { id: '1', label: 'Node 1', color: '#ffffff', x: 100, y: 0, width: 100, height: 50 }, + '2': { id: '2', label: 'Node 2', color: '#ffffff', x: 100, y: 200, width: 100, height: 50 }, +}; -const initialEdges: GraphLink[] = [ - { +const initialEdges: Record = { + 'e1-2': { id: 'e1-2', source: '1', target: '2', @@ -22,9 +22,9 @@ const initialEdges: GraphLink[] = [ }, }, }, -]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; function ElementInput({ id, label }: Readonly) { const { set } = useCellActions(); @@ -56,7 +56,7 @@ function Main() {
- {elements.map((item) => { + {Object.values(elements).map((item) => { return ; })}
diff --git a/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx b/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx index dce5457d7..d78db8e46 100644 --- a/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx +++ b/packages/joint-react/src/stories/examples/with-proximity-link/code.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { GraphProvider, Paper, useNodeSize } from '@joint/react'; +import { GraphProvider, Paper, useNodeSize, useCellId } from '@joint/react'; import '../index.css'; import { useRef } from 'react'; import { shapes, util } from '@joint/core'; @@ -7,14 +7,14 @@ import { PAPER_CLASSNAME, SECONDARY } from 'storybook-config/theme'; import type { dia } from '../../../../../joint-core/types'; import { useCellChangeEffect } from '../../../hooks/use-cell-change-effect'; -const initialElements = [ - { id: '1', label: 'Node 1', x: 100, y: 0 }, - { id: '2', label: 'Node 2', x: 100, y: 200 }, - { id: '3', label: 'Node 3', x: 280, y: 100 }, - { id: '4', label: 'Node 4', x: 0, y: 100 }, -]; +const initialElements: Record = { + '1': { label: 'Node 1', x: 100, y: 0 }, + '2': { label: 'Node 2', x: 100, y: 200 }, + '3': { label: 'Node 3', x: 280, y: 100 }, + '4': { label: 'Node 4', x: 0, y: 100 }, +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; class DashedLink extends shapes.standard.Link { defaults() { @@ -106,7 +106,8 @@ function createProximityLinks( } } -function ResizableNode({ id, label }: Readonly) { +function ResizableNode({ label }: Readonly) { + const id = useCellId(); const nodeRef = useRef(null); const managedLinksRef = useRef>(new Set()); diff --git a/packages/joint-react/src/stories/examples/with-render-link/code.tsx b/packages/joint-react/src/stories/examples/with-render-link/code.tsx index 776bba12a..29eed2dad 100644 --- a/packages/joint-react/src/stories/examples/with-render-link/code.tsx +++ b/packages/joint-react/src/stories/examples/with-render-link/code.tsx @@ -1,58 +1,60 @@ - /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import '../index.css'; -import { GraphProvider, Link, Paper, type RenderLink } from '@joint/react'; +import { GraphProvider, Paper, type RenderLink, useCellId, useLinkLayout } from '@joint/react'; import { useCallback } from 'react'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; import { REACT_LINK_TYPE } from '../../../models/react-link'; -const initialElements = [ - { id: '1', label: 'Node 1', x: 100, y: 0 }, - { id: '2', label: 'Node 2', x: 100, y: 200 }, - { id: '3', label: 'Node 3', x: 300, y: 100 }, -]; +const initialElements: Record = { + '1': { label: 'Node 1', x: 100, y: 0 }, + '2': { label: 'Node 2', x: 100, y: 200 }, + '3': { label: 'Node 3', x: 300, y: 100 }, +}; -const initialLinks = [ - { - id: 'link-1', +const initialLinks: Record = { + 'link-1': { type: REACT_LINK_TYPE, source: '1', target: '2', }, - { - id: 'link-2', + 'link-2': { type: REACT_LINK_TYPE, source: '2', target: '3', }, -]; +}; + +function LinkPath() { + const layout = useLinkLayout(); + const id = useCellId(); + + if (!layout) { + return null; + } + + // Calculate midpoint for label + const midX = (layout.sourceX + layout.targetX) / 2; + const midY = (layout.sourceY + layout.targetY) / 2; + + return ( + + + +
Link {id}
+
+
+ ); +} function Main() { const renderElement = useCallback( - (element: { id: string; label: string }) => {element.label}, + (element: { label: string }) => {element.label}, [] ); - const renderLink: RenderLink = useCallback( - (link) => ( - <> - - - -
- Link {link.id} -
-
-
- - ), - [] - ); + const renderLink: RenderLink = useCallback(() => , []); return (
diff --git a/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx b/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx index eedf3ef94..2df600f9d 100644 --- a/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-resizable-node/code.tsx @@ -4,14 +4,13 @@ import '../index.css'; import { useCallback, useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -const initialElements = [ - { id: '1', label: 'Node 1', x: 100, y: 0 }, - { id: '2', label: 'Node 2', x: 100, y: 200 }, -]; +const initialElements: Record = { + '1': { label: 'Node 1', x: 100, y: 0 }, + '2': { label: 'Node 2', x: 100, y: 200 }, +}; -const initialEdges = [ - { - id: 'e1-2', +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -20,9 +19,9 @@ const initialEdges = [ }, }, }, -]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; function ResizableNode({ label }: Readonly) { const nodeRef = useRef(null); @@ -30,7 +29,7 @@ function ResizableNode({ label }: Readonly) { const node = nodeRef.current; if (!node) return; - // Get the node’s bounding rectangle + // Get the node's bounding rectangle const rect = node.getBoundingClientRect(); const threshold = 20; // pixels from the bottom-right corner considered as resize area @@ -61,7 +60,7 @@ function ResizableNode({ label }: Readonly) { function Main() { const elementsSize = useElements((items) => - items.map(({ width, height }) => `${width} x ${height}`) + Object.values(items).map(({ width, height }) => `${width} x ${height}`) ); return ( diff --git a/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx b/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx index 04e92ad7e..b6e7b6ac5 100644 --- a/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx @@ -5,13 +5,13 @@ import { useCallback, useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements = [ - { id: '1', label: 'Node 1', x: 20, y: 100 }, - { id: '2', label: 'Node 2', x: 200, y: 100 }, -]; +const initialElements: Record = { + '1': { id: '1', label: 'Node 1', x: 20, y: 100 }, + '2': { id: '2', label: 'Node 2', x: 200, y: 100 }, +}; -const initialEdges = [ - { +const initialEdges: Record = { + 'e1-2': { id: 'e1-2', source: '1', target: '2', @@ -21,9 +21,9 @@ const initialEdges = [ }, }, }, -]; +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; function RotatableNode({ label, id }: Readonly) { const paper = usePaper(); @@ -85,7 +85,7 @@ function RotatableNode({ label, id }: Readonly) { function Main() { const elementRotation = useElements((items) => - items.map(({ angle }) => `${angle?.toString().padStart(3, '0')} deg`) + Object.values(items).map(({ angle }) => `${angle?.toString().padStart(3, '0')} deg`) ); return ( diff --git a/packages/joint-react/src/stories/examples/with-svg-node/code.tsx b/packages/joint-react/src/stories/examples/with-svg-node/code.tsx index ff640af49..375d6a563 100644 --- a/packages/joint-react/src/stories/examples/with-svg-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-svg-node/code.tsx @@ -10,9 +10,8 @@ import { } from '@joint/react'; import { useCallback, useRef } from 'react'; -const initialEdges = [ - { - id: 'e1-2', +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -21,14 +20,14 @@ const initialEdges = [ }, }, }, -]; +}; -const initialElements = [ - { id: '1', label: 'Node 1', x: 100, y: 0 }, - { id: '2', label: 'Node 2', x: 100, y: 200 }, -]; +const initialElements: Record = { + '1': { label: 'Node 1', x: 100, y: 0 }, + '2': { label: 'Node 2', x: 100, y: 200 }, +}; -type BaseElementWithData = (typeof initialElements)[number]; +type BaseElementWithData = (typeof initialElements)[string]; function RenderedRect({ label }: Readonly) { const textMargin = 20; diff --git a/packages/joint-react/src/stories/introduction.mdx b/packages/joint-react/src/stories/introduction.mdx index 9c3d4d314..50209d2e1 100644 --- a/packages/joint-react/src/stories/introduction.mdx +++ b/packages/joint-react/src/stories/introduction.mdx @@ -69,16 +69,16 @@ Use the graph APIs to update state. Hooks provide convenient access: - {getAPIDocumentationLink('usePaper')}: Access the JointJS [Paper](https://docs.jointjs.com/learn/quickstart/paper/) ### πŸ”Ή Creating Nodes and Links -Elements and links are plain objects with at least an `id` field and geometry properties. +Elements and links are plain objects with geometry properties. They are stored as Records keyed by their ID. ```ts -const initialElements = [ - { id: '1', type: 'rect', x: 10, y: 10, width: 100, height: 100 }, -]; +const initialElements = { + '1': { type: 'rect', x: 10, y: 10, width: 100, height: 100 }, +}; -const initialLinks = [ - { id: '1-2', source: '1', target: '2' }, -]; +const initialLinks = { + '1-2': { source: '1', target: '2' }, +}; ``` --- diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx index 4239a4d45..8d38779c2 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx @@ -56,14 +56,13 @@ import type { Update } from '../../../utils/create-state'; */ type CustomElement = GraphElement & { label: string }; -const defaultElements: CustomElement[] = [ - { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, - { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]; - -const defaultLinks: GraphLink[] = [ - { - id: 'e1-2', +const defaultElements: Record = { + '1': { label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'World', x: 100, y: 200, width: 100, height: 50 }, +}; + +const defaultLinks: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -72,7 +71,7 @@ const defaultLinks: GraphLink[] = [ }, }, }, -]; +}; // ============================================================================ // STEP 2: Custom Element Renderer @@ -101,12 +100,14 @@ const jotaiStore = createStore(); * Jotai atom for graph elements. * Atoms are the building blocks of Jotai - they hold state. */ -const elementsAtom = atom(defaultElements as GraphElement[]); +const elementsAtom = atom>( + defaultElements as Record +); /** * Jotai atom for graph links. */ -const linksAtom = atom(defaultLinks as GraphLink[]); +const linksAtom = atom>(defaultLinks as Record); // ============================================================================ // STEP 4: Create Jotai Adapter Hook @@ -230,8 +231,9 @@ function PaperApp({ store }: Readonly) { const currentState = store.getSnapshot(); // Create a new element + const newId = Math.random().toString(36).slice(7); const newElement: CustomElement = { - id: Math.random().toString(36).slice(7), + id: newId, label: 'New Node', x: Math.random() * 200, y: Math.random() * 200, @@ -242,8 +244,8 @@ function PaperApp({ store }: Readonly) { // Update the store with the new element // This will automatically sync to the graph and update Jotai atoms store.setState({ - elements: [...currentState.elements, newElement], - links: currentState.links as GraphLink[], + elements: { ...currentState.elements, [newId]: newElement }, + links: currentState.links as Record, }); }} > @@ -256,26 +258,33 @@ function PaperApp({ store }: Readonly) { // Get current state from the store const currentState = store.getSnapshot(); - if (currentState.elements.length === 0) { + const elementIds = Object.keys(currentState.elements); + if (elementIds.length === 0) { return; } // Remove the last element - const newElements = currentState.elements.slice(0, -1); - const removedElementId = currentState.elements.at(-1)?.id; + const removedElementId = elementIds.at(-1); + if (!removedElementId) { + return; + } + + // eslint-disable-next-line sonarjs/no-unused-vars + const { [removedElementId]: _removed, ...newElements } = currentState.elements; // Remove links connected to the removed element - const newLinks = removedElementId - ? currentState.links.filter( - (link) => link.source !== removedElementId && link.target !== removedElementId - ) - : currentState.links; + const newLinks: Record = {}; + for (const [id, link] of Object.entries(currentState.links)) { + if (link.source !== removedElementId && link.target !== removedElementId) { + newLinks[id] = link; + } + } // Update the store // This will automatically sync to the graph and update Jotai atoms store.setState({ elements: newElements, - links: newLinks as GraphLink[], + links: newLinks, }); }} > diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx index e7cc8ea9a..9989a5395 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-peerjs.tsx @@ -47,7 +47,7 @@ import { } from '@joint/react'; import '../../examples/index.css'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; -import { useState, useEffect, useRef } from 'react'; +import { useState } from 'react'; import Peer, { type DataConnection } from 'peerjs'; import type { GraphStoreSnapshot } from '../../../store/graph-store'; import type { Update } from '../../../utils/create-state'; @@ -61,14 +61,13 @@ import type { Update } from '../../../utils/create-state'; */ type CustomElement = GraphElement & { label: string }; -const defaultElements: CustomElement[] = [ - { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, - { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]; +const defaultElements: Record = { + '1': { label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'World', x: 100, y: 200, width: 100, height: 50 }, +}; -const defaultLinks: GraphLink[] = [ - { - id: 'e1-2', +const defaultLinks: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -77,7 +76,7 @@ const defaultLinks: GraphLink[] = [ }, }, }, -]; +}; // ============================================================================ // STEP 2: Custom Element Renderer @@ -102,8 +101,8 @@ function RenderItem(props: CustomElement) { */ interface StateSyncMessage { type: 'state-update'; - elements: GraphElement[]; - links: GraphLink[]; + elements: Record; + links: Record; } /** @@ -122,19 +121,19 @@ interface StateSyncMessage { type ConnectionStatus = 'disconnected' | 'connecting' | 'connected'; function createPeerJSStore( - initialElements: GraphElement[], - initialLinks: GraphLink[] + initialElements: Record, + initialLinks: Record, + callbacks: { + onPeerIdChange: (id: string | null) => void; + onConnectionStatusChange: (status: ConnectionStatus) => void; + onConnectedPeerIdChange: (id: string | null) => void; + } ): { store: ExternalGraphStore; peerId: string | null; connectedPeerId: string | null; connectionStatus: ConnectionStatus; connectToPeer: (remotePeerId: string) => void; - setCallbacks: ( - peerIdCb: (id: string | null) => void, - statusCb: (status: ConnectionStatus) => void, - connectedIdCb: (id: string | null) => void - ) => void; } { // Local state let currentState: GraphStoreSnapshot = { @@ -218,6 +217,9 @@ function createPeerJSStore( // Notify subscribers notifySubscribers(); + // eslint-disable-next-line no-console + console.log('[PeerJS] setState called, connectionStatus:', connectionStatus, 'connections:', connectionsRef.length); + // Send to peers (if connected and not receiving an update) if (connectionStatus === 'connected' && !isReceivingUpdateRef.current) { sendStateUpdate(newState); @@ -225,10 +227,8 @@ function createPeerJSStore( }, }; - // Callbacks for React state updates - let onPeerIdChange: ((id: string | null) => void) | null = null; - let onConnectionStatusChange: ((status: ConnectionStatus) => void) | null = null; - let onConnectedPeerIdChange: ((id: string | null) => void) | null = null; + // Callbacks for React state updates (provided at creation time to avoid race conditions) + const { onPeerIdChange, onConnectionStatusChange, onConnectedPeerIdChange } = callbacks; // Initialize PeerJS peer const initializePeer = () => { @@ -237,9 +237,7 @@ function createPeerJSStore( peer.on('open', (id) => { peerId = id; - if (onPeerIdChange) { - onPeerIdChange(id); - } + onPeerIdChange(id); }); // Handle incoming connections @@ -247,12 +245,8 @@ function createPeerJSStore( connectionStatus = 'connected'; connectedPeerId = conn.peer; connectionsRef.push(conn); - if (onConnectionStatusChange) { - onConnectionStatusChange('connected'); - } - if (onConnectedPeerIdChange) { - onConnectedPeerIdChange(conn.peer); - } + onConnectionStatusChange('connected'); + onConnectedPeerIdChange(conn.peer); // Handle incoming data conn.on('data', (data) => { @@ -268,12 +262,8 @@ function createPeerJSStore( if (connectionsRef.length === 0) { connectionStatus = 'disconnected'; connectedPeerId = null; - if (onConnectionStatusChange) { - onConnectionStatusChange('disconnected'); - } - if (onConnectedPeerIdChange) { - onConnectedPeerIdChange(null); - } + onConnectionStatusChange('disconnected'); + onConnectedPeerIdChange(null); } }); }); @@ -283,9 +273,7 @@ function createPeerJSStore( console.error('PeerJS error:', error); if (error.type === 'peer-unavailable') { connectionStatus = 'disconnected'; - if (onConnectionStatusChange) { - onConnectionStatusChange('disconnected'); - } + onConnectionStatusChange('disconnected'); alert('Peer not found. Make sure the peer ID is correct and the peer is online.'); } }); @@ -298,32 +286,85 @@ function createPeerJSStore( } connectionStatus = 'connecting'; - if (onConnectionStatusChange) { - onConnectionStatusChange('connecting'); - } + onConnectionStatusChange('connecting'); + + // eslint-disable-next-line no-console + console.log('[PeerJS] Attempting to connect to:', remotePeerId, 'peerRef.open:', peerRef.open); const conn = peerRef.connect(remotePeerId); - conn.on('open', () => { + // eslint-disable-next-line no-console + console.log('[PeerJS] Connection object created, conn.open:', conn.open); + + // Check underlying WebRTC connection state + if (conn.peerConnection) { + // eslint-disable-next-line no-console + console.log('[PeerJS] RTCPeerConnection state:', conn.peerConnection.connectionState); + conn.peerConnection.onconnectionstatechange = () => { + // eslint-disable-next-line no-console + console.log('[PeerJS] RTCPeerConnection state changed:', conn.peerConnection?.connectionState); + }; + conn.peerConnection.oniceconnectionstatechange = () => { + // eslint-disable-next-line no-console + console.log('[PeerJS] ICE connection state:', conn.peerConnection?.iceConnectionState); + }; + } else { + // eslint-disable-next-line no-console + console.log('[PeerJS] peerConnection not yet available'); + } + + const handleConnectionOpen = () => { + // eslint-disable-next-line no-console + console.log('[PeerJS] Connection opened to:', remotePeerId); connectionStatus = 'connected'; connectedPeerId = remotePeerId; connectionsRef.push(conn); - if (onConnectionStatusChange) { - onConnectionStatusChange('connected'); - } - if (onConnectedPeerIdChange) { - onConnectedPeerIdChange(remotePeerId); - } + onConnectionStatusChange('connected'); + onConnectedPeerIdChange(remotePeerId); // Send current state to the newly connected peer sendStateUpdate(currentState); - }); + }; + + // Check if connection is already open (can happen before listener is attached) + if (conn.open) { + // eslint-disable-next-line no-console + console.log('[PeerJS] Connection already open, calling handler immediately'); + handleConnectionOpen(); + } else { + // eslint-disable-next-line no-console + console.log('[PeerJS] Connection not yet open, waiting for open event'); + conn.on('open', handleConnectionOpen); + + // Workaround: PeerJS sometimes doesn't fire 'open' event - poll connection state + let connectionHandled = false; + const pollInterval = setInterval(() => { + // eslint-disable-next-line no-console + console.log('[PeerJS] Polling connection state, conn.open:', conn.open); + if (conn.open && !connectionHandled) { + connectionHandled = true; + clearInterval(pollInterval); + // eslint-disable-next-line no-console + console.log('[PeerJS] Connection opened via polling'); + handleConnectionOpen(); + } + }, 500); + + // Clear polling after 10 seconds to avoid memory leak + setTimeout(() => { + clearInterval(pollInterval); + }, 10_000); + } conn.on('data', (data) => { + // eslint-disable-next-line no-console + console.log('[PeerJS] Received data from peer:', data); handlePeerUpdate(data as StateSyncMessage); }); conn.on('close', () => { + // eslint-disable-next-line no-console + console.log('[PeerJS] Connection closed'); const index = connectionsRef.indexOf(conn); if (index !== -1) { connectionsRef.splice(index, 1); @@ -331,12 +372,8 @@ function createPeerJSStore( if (connectionsRef.length === 0) { connectionStatus = 'disconnected'; connectedPeerId = null; - if (onConnectionStatusChange) { - onConnectionStatusChange('disconnected'); - } - if (onConnectedPeerIdChange) { - onConnectedPeerIdChange(null); - } + onConnectionStatusChange('disconnected'); + onConnectedPeerIdChange(null); } }); @@ -344,9 +381,7 @@ function createPeerJSStore( // eslint-disable-next-line no-console console.error('Connection error:', error); connectionStatus = 'disconnected'; - if (onConnectionStatusChange) { - onConnectionStatusChange('disconnected'); - } + onConnectionStatusChange('disconnected'); }); }; @@ -365,15 +400,6 @@ function createPeerJSStore( return connectionStatus; }, connectToPeer, - setCallbacks: ( - peerIdCb: (id: string | null) => void, - statusCb: (status: ConnectionStatus) => void, - connectedIdCb: (id: string | null) => void - ) => { - onPeerIdChange = peerIdCb; - onConnectionStatusChange = statusCb; - onConnectedPeerIdChange = connectedIdCb; - }, }; } @@ -399,8 +425,9 @@ function PaperApp({ store }: Readonly) { const currentState = store.getSnapshot(); // Create a new element + const newId = Math.random().toString(36).slice(7); const newElement: CustomElement = { - id: Math.random().toString(36).slice(7), + id: newId, label: 'New Node', x: Math.random() * 200, y: Math.random() * 200, @@ -411,8 +438,8 @@ function PaperApp({ store }: Readonly) { // Update the store with the new element // This will automatically sync to peers via PeerJS store.setState({ - elements: [...currentState.elements, newElement], - links: [...currentState.links], + elements: { ...currentState.elements, [newId]: newElement }, + links: { ...currentState.links }, }); }} > @@ -425,26 +452,32 @@ function PaperApp({ store }: Readonly) { // Get current state from the store const currentState = store.getSnapshot(); - if (currentState.elements.length === 0) { + const elementIds = Object.keys(currentState.elements); + if (elementIds.length === 0) { return; } // Remove the last element - const newElements = currentState.elements.slice(0, -1); - const removedElementId = currentState.elements.at(-1)?.id; + const removedElementId = elementIds.at(-1); + if (!removedElementId) { + return; + } + // eslint-disable-next-line sonarjs/no-unused-vars + const { [removedElementId]: _removed, ...newElements } = currentState.elements; // Remove links connected to the removed element - const newLinks = removedElementId - ? currentState.links.filter( - (link) => link.source !== removedElementId && link.target !== removedElementId - ) - : currentState.links; + const newLinks: Record = {}; + for (const [id, link] of Object.entries(currentState.links)) { + if (link.source !== removedElementId && link.target !== removedElementId) { + newLinks[id] = link; + } + } // Update the store // This will automatically sync to peers via PeerJS store.setState({ elements: newElements, - links: [...newLinks], + links: newLinks, }); }} > @@ -466,17 +499,19 @@ function Main(props: Readonly) { const [connectionStatus, setConnectionStatus] = useState('disconnected'); const [copyFeedback, setCopyFeedback] = useState(false); - // Create PeerJS store (only once) - const peerJSStoreRef = useRef(createPeerJSStore(defaultElements, defaultLinks)); - - // Set up callbacks for React state updates - useEffect(() => { - peerJSStoreRef.current.setCallbacks(setPeerId, setConnectionStatus, setConnectedPeerId); - }, []); + // Create PeerJS store (only once) with callbacks passed at creation time + // This avoids the race condition where peer.on('open') fires before useEffect runs + const [peerJSStore] = useState(() => + createPeerJSStore(defaultElements, defaultLinks, { + onPeerIdChange: setPeerId, + onConnectionStatusChange: setConnectionStatus, + onConnectedPeerIdChange: setConnectedPeerId, + }) + ); const handleConnect = () => { if (remotePeerId.trim()) { - peerJSStoreRef.current.connectToPeer(remotePeerId.trim()); + peerJSStore.connectToPeer(remotePeerId.trim()); setConnectionStatus('connecting'); } }; @@ -573,8 +608,8 @@ function Main(props: Readonly) {
{/* Graph */} - - + +
); diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx index 018c490f1..8e3d7f9c6 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-redux.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-dynamic-delete */ /* eslint-disable @eslint-react/hooks-extra/no-direct-set-state-in-use-effect */ /* eslint-disable sonarjs/pseudo-random */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ @@ -71,10 +72,10 @@ import type { Update } from '../../../utils/create-state'; * History is managed automatically by redux-undo, so we don't need to include it here. */ interface GraphState { - /** Array of all elements (nodes) in the graph */ - readonly elements: GraphElement[]; - /** Array of all links (edges) in the graph */ - readonly links: GraphLink[]; + /** Record of all elements (nodes) in the graph keyed by ID */ + readonly elements: Record; + /** Record of all links (edges) in the graph keyed by ID */ + readonly links: Record; } // ============================================================================ @@ -89,17 +90,16 @@ type CustomElement = GraphElement & { label: string }; /** * Initial elements for the graph. */ -const defaultElements: CustomElement[] = [ - { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, - { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]; +const defaultElements: Record = { + '1': { label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'World', x: 100, y: 200, width: 100, height: 50 }, +}; /** * Initial links for the graph. */ -const defaultLinks: GraphLink[] = [ - { - id: 'e1-2', +const defaultLinks: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -108,7 +108,7 @@ const defaultLinks: GraphLink[] = [ }, }, }, -]; +}; /** * Redux slice for managing graph state. @@ -118,32 +118,40 @@ const defaultLinks: GraphLink[] = [ const graphSlice = createSlice({ name: 'graph', initialState: { - elements: defaultElements as GraphElement[], - links: defaultLinks as GraphLink[], + elements: defaultElements as Record, + links: defaultLinks as Record, } satisfies GraphState, reducers: { /** * Adds a new element to the graph. + * The element must include an 'id' property that will be used as the key. */ - addElement: (state, action: PayloadAction) => { - state.elements.push(action.payload); + addElement: (state, action: PayloadAction<{ id: string } & GraphElement>) => { + const { id, ...element } = action.payload; + state.elements[id] = element; }, /** * Removes the last element from the graph. * Also removes all links connected to that element. */ removeLastElement: (state) => { - if (state.elements.length === 0) { + const elementIds = Object.keys(state.elements); + if (elementIds.length === 0) { return; } // Remove the last element - const removedElementId = state.elements.at(-1)?.id; - state.elements.pop(); + const removedElementId = elementIds.at(-1); + if (!removedElementId) { + return; + } + delete state.elements[removedElementId]; // Remove all links connected to the removed element if (removedElementId) { - state.links = state.links.filter( - (link) => link.source !== removedElementId && link.target !== removedElementId - ); + for (const [id, link] of Object.entries(state.links)) { + if (link.source === removedElementId || link.target === removedElementId) { + delete state.links[id]; + } + } } }, /** @@ -414,15 +422,15 @@ function ReduxConnectedPaperApp() { onClick={() => { // Dispatch Redux action to add a new element // redux-undo automatically saves the current state to history + const newId = Math.random().toString(36).slice(7); const newElement: CustomElement = { - id: Math.random().toString(36).slice(7), label: 'New Node', x: Math.random() * 200, y: Math.random() * 200, width: 100, height: 50, - } as CustomElement; - dispatch(addElement(newElement)); + }; + dispatch(addElement({ id: newId, ...newElement })); }} > Add Element @@ -495,6 +503,3 @@ function ReduxConnectedPaperApp() { export default function App(props: Readonly) { return
; } - - - diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx index e414e069a..af2bb6f00 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode-zustand.tsx @@ -56,14 +56,13 @@ import type { Update } from '../../../utils/create-state'; */ type CustomElement = GraphElement & { label: string }; -const defaultElements: CustomElement[] = [ - { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, - { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]; - -const defaultLinks: GraphLink[] = [ - { - id: 'e1-2', +const defaultElements: Record = { + '1': { label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'World', x: 100, y: 200, width: 100, height: 50 }, +}; + +const defaultLinks: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -72,7 +71,7 @@ const defaultLinks: GraphLink[] = [ }, }, }, -]; +}; // ============================================================================ // STEP 2: Custom Element Renderer @@ -95,12 +94,12 @@ function RenderItem(props: CustomElement) { * Zustand store interface for graph state. */ interface GraphStore { - /** Array of all elements (nodes) in the graph */ - elements: GraphElement[]; - /** Array of all links (edges) in the graph */ - links: GraphLink[]; + /** Record of all elements (nodes) in the graph keyed by ID */ + elements: Record; + /** Record of all links (edges) in the graph keyed by ID */ + links: Record; /** Action to add a new element */ - addElement: (data: GraphElement) => void; + addElement: (id: string, data: GraphElement) => void; /** Action to remove the last element */ removeLastElement: () => void; /** Action to update the graph state (used by adapter) */ @@ -112,27 +111,34 @@ interface GraphStore { * Zustand stores are simple - just define state and actions in one place. */ const useGraphStore = create((set) => ({ - elements: defaultElements as GraphElement[], - links: defaultLinks as GraphLink[], + elements: defaultElements as Record, + links: defaultLinks as Record, - addElement: (element) => { + addElement: (id: string, element: GraphElement) => { set((state) => ({ - elements: [...state.elements, element], + elements: { ...state.elements, [id]: element }, })); }, removeLastElement: () => { set((state) => { - if (state.elements.length === 0) { + const elementIds = Object.keys(state.elements); + if (elementIds.length === 0) { + return state; + } + const removedElementId = elementIds.at(-1); + if (!removedElementId) { return state; } - const removedElementId = state.elements.at(-1)?.id; - const newElements = state.elements.slice(0, -1); - const newLinks = removedElementId - ? state.links.filter( - (link) => link.source !== removedElementId && link.target !== removedElementId - ) - : state.links; + // eslint-disable-next-line sonarjs/no-unused-vars + const { [removedElementId]: _removed, ...newElements } = state.elements; + + const newLinks: Record = {}; + for (const [id, link] of Object.entries(state.links)) { + if (link.source !== removedElementId && link.target !== removedElementId) { + newLinks[id] = link; + } + } return { elements: newElements, @@ -249,15 +255,15 @@ function PaperApp() { className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded font-medium transition-colors" onClick={() => { // Use Zustand action to add a new element + const newId = Math.random().toString(36).slice(7); const newElement: CustomElement = { - id: Math.random().toString(36).slice(7), label: 'New Node', x: Math.random() * 200, y: Math.random() * 200, width: 100, height: 50, - } as CustomElement; - addElement(newElement); + }; + addElement(newId, newElement); }} > Add Element diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx index 7a5c92d21..37f741b1e 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-controlled-mode.tsx @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-unused-vars */ /* eslint-disable sonarjs/pseudo-random */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ @@ -71,10 +72,10 @@ type CustomLink = GraphLink; * - x, y: position on the canvas * - width, height: dimensions */ -const defaultElements: CustomElement[] = [ - { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, - { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, -]; +const defaultElements: Record = { + '1': { label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'World', x: 100, y: 200, width: 100, height: 50 }, +}; /** * Initial links (edges) for the graph. @@ -84,9 +85,8 @@ const defaultElements: CustomElement[] = [ * - target: id of the target element * - attrs: visual attributes (colors, stroke width, etc.) */ -const defaultLinks: CustomLink[] = [ - { - id: 'e1-2', +const defaultLinks: Record = { + 'e1-2': { source: '1', target: '2', attrs: { @@ -95,7 +95,7 @@ const defaultLinks: CustomLink[] = [ }, }, }, -]; +}; // ============================================================================ // STEP 3: Custom Element Renderer @@ -132,10 +132,10 @@ function RenderItem(props: CustomElement) { * In controlled mode, all state changes must go through these setters. */ interface PaperAppProps { - /** Function to update the elements array */ - readonly onElementsChange: Dispatch>; - /** Function to update the links array */ - readonly onLinksChange: Dispatch>; + /** Function to update the elements Record */ + readonly onElementsChange: Dispatch>>; + /** Function to update the links Record */ + readonly onLinksChange: Dispatch>>; } /** @@ -230,28 +230,30 @@ function PaperApp({ onElementsChange, onLinksChange }: Readonly) type="button" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded font-medium transition-colors" onClick={() => { - // Step 1: Create a new element object + // Step 1: Generate a unique ID for the new element + // Math.random().toString(36) creates a base-36 string + // .slice(7) removes the "0." prefix + const newId = Math.random().toString(36).slice(7); + + // Step 2: Create a new element object (without id - id is the Record key) // This is just a plain JavaScript object - not a JointJS element yet const newElement: CustomElement = { - // Generate a unique ID using random string - // Math.random().toString(36) creates a base-36 string - // .slice(7) removes the "0." prefix - id: Math.random().toString(36).slice(7), label: 'New Node', // Random position to spread elements across the canvas x: Math.random() * 200, y: Math.random() * 200, width: 100, height: 50, - } as CustomElement; + }; - // Step 2: Update React state using functional update + // Step 3: Update React state using functional update // This is the KEY to controlled mode - we update state, not the graph // The functional form (prev) => newValue ensures we use the latest state onElementsChange((elements) => { - // Create a new array with all existing elements plus the new one - // We use spread operator to create a new array (immutability) - return [...elements, newElement]; + // Create a new Record with all existing elements plus the new one + // We use spread operator to create a new object (immutability) + // The id is the key, not a property of the element + return { ...elements, [newId]: newElement }; }); // Step 3: That's it! GraphProvider will handle the rest: @@ -306,24 +308,28 @@ function PaperApp({ onElementsChange, onLinksChange }: Readonly) // Step 1: Update elements state by removing the last element onElementsChange((elements) => { // Check if there are any elements to remove - if (elements.length === 0) { + const elementIds = Object.keys(elements); + if (elementIds.length === 0) { // No elements to remove, return current state unchanged return elements; } - // Create a new array without the last element - // slice(0, -1) returns all elements except the last one - const newElements = elements.slice(0, -1); + // Create a new Record without the last element + const removedElementId = elementIds.at(-1); + if (!removedElementId) { + return elements; + } + const { [removedElementId]: _, ...newElements } = elements; // Step 2: If no elements remain, clear all links // Links require source and target elements to exist // If we remove all elements, links become invalid - if (newElements.length === 0) { + if (Object.keys(newElements).length === 0) { // Clear all links since there are no elements left - onLinksChange([]); + onLinksChange({}); } - // Return the new elements array + // Return the new elements Record return newElements; }); @@ -381,8 +387,8 @@ function PaperApp({ onElementsChange, onLinksChange }: Readonly) function Main(props: Readonly) { // Create React state for elements and links // These are the single source of truth for the graph - const [elements, setElements] = useState(defaultElements); - const [links, setLinks] = useState(defaultLinks); + const [elements, setElements] = useState>(defaultElements); + const [links, setLinks] = useState>(defaultLinks); return ( ) { onElementsChange={setElements} onLinksChange={setLinks} > - {/* + {/* Pass state setters to child component so it can update the graph by updating React state. The type assertions are needed because GraphElement/GraphLink are more generic than CustomElement/CustomLink. */} >} - onLinksChange={setLinks as Dispatch>} + onElementsChange={setElements as Dispatch>>} + onLinksChange={setLinks as Dispatch>>} /> ); diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx index e00cc65a3..bacdb65b0 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-html-renderer.tsx @@ -14,16 +14,15 @@ import { BUTTON_CLASSNAME } from 'storybook-config/theme'; // Define element type with custom properties type CustomElement = GraphElement & { data: { label: string } }; -// Define initial elements -const initialElements: CustomElement[] = [ - { id: '1', data: { label: 'Hello' }, x: 100, y: 0, width: 100, height: 25 }, - { id: '2', data: { label: 'World' }, x: 100, y: 200, width: 100, height: 25 }, -]; +// Define initial elements as Record +const initialElements: Record = { + '1': { data: { label: 'Hello' }, x: 100, y: 0, width: 100, height: 25 }, + '2': { data: { label: 'World' }, x: 100, y: 200, width: 100, height: 25 }, +}; -// Define initial edges -const initialEdges: GraphLink[] = [ - { - id: 'e1-2', +// Define initial edges as Record +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', type: 'standard.Link', // If you define type, it provides intellisense support @@ -34,7 +33,7 @@ const initialEdges: GraphLink[] = [ }, }, }, -]; +}; let zoomLevel = 1; @@ -114,7 +113,6 @@ function Main() { export default function App(props: Readonly) { return ( = { + '1': { label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'World', x: 100, y: 200, width: 100, height: 50 }, +}; -// define initial edges -const initialEdges: GraphLink[] = [ - { - id: 'e1-2', +// define initial edges as Record +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', type: 'standard.Link', // if define type, it provide intellisense support @@ -34,7 +33,7 @@ const initialEdges: GraphLink[] = [ }, }, }, -]; +}; function RenderItem(props: CustomElement) { const { label, width, height } = props; const elementRef = React.useRef(null); diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx b/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx index 69ccfc3e6..7ffeb764c 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/code-svg.tsx @@ -12,16 +12,15 @@ import { // define element type with custom properties type CustomElement = GraphElement & { color: string }; -// define initial elements -const initialElements: CustomElement[] = [ - { id: '1', color: PRIMARY, x: 100, y: 0, width: 100, height: 25 }, - { id: '2', color: PRIMARY, x: 100, y: 200, width: 100, height: 25 }, -]; +// define initial elements as Record +const initialElements: Record = { + '1': { color: PRIMARY, x: 100, y: 0, width: 100, height: 25 }, + '2': { color: PRIMARY, x: 100, y: 200, width: 100, height: 25 }, +}; -// define initial edges -const initialEdges: GraphLink[] = [ - { - id: 'e1-2', +// define initial edges as Record +const initialEdges: Record = { + 'e1-2': { source: '1', target: '2', type: 'standard.Link', // if define type, it provide intellisense support @@ -32,7 +31,7 @@ const initialEdges: GraphLink[] = [ }, }, }, -]; +}; function RenderItem({ width, height, color }: CustomElement) { return ; diff --git a/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx b/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx index 48f6f8182..7f6e6dd27 100644 --- a/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx +++ b/packages/joint-react/src/stories/tutorials/step-by-step/docs.mdx @@ -42,23 +42,23 @@ Welcome! This guide will help you get started with the new `@joint/react` librar ### a. Elements (Nodes) -Elements are plain objects with at least an `id` and geometry properties (`x`, `y`, `width`, `height`). +Elements are plain objects with geometry properties (`x`, `y`, `width`, `height`). They are stored as Records keyed by their ID. ```tsx -const elements = [ - { id: '1', label: 'Node 1', x: 100, y: 0, width: 100, height: 25 }, - { id: '2', label: 'Node 2', x: 100, y: 200, width: 100, height: 25 }, -]; +const elements = { + '1': { label: 'Node 1', x: 100, y: 0, width: 100, height: 25 }, + '2': { label: 'Node 2', x: 100, y: 200, width: 100, height: 25 }, +}; ``` ### b. Links (Edges) -Links are plain objects with `id`, `source`, and `target` properties. +Links are plain objects with `source` and `target` properties. They are stored as Records keyed by their ID. ```tsx -const links = [ - { id: 'l1', source: '1', target: '2' }, -]; +const links = { + 'l1': { source: '1', target: '2' }, +}; ``` --- @@ -207,10 +207,10 @@ ${CodeControlledMode} #### How It Works: -1. **State Management**: Use `useState` to manage elements and links: +1. **State Management**: Use `useState` to manage elements and links as Records: ```tsx - const [elements, setElements] = useState(defaultElements); - const [links, setLinks] = useState(defaultLinks); + const [elements, setElements] = useState>(defaultElements); + const [links, setLinks] = useState>(defaultLinks); ``` 2. **Controlled Mode**: Pass state and change handlers to `GraphProvider`: @@ -489,10 +489,10 @@ You can render multiple `Paper` instances inside one `GraphProvider` to create f ```tsx import { GraphProvider } from '@joint/react'; -const elements = [ - { id: '1', label: 'A', x: 50, y: 50, width: 80, height: 40 }, - { id: '2', label: 'B', x: 250, y: 180, width: 80, height: 40 }, -]; +const elements = { + '1': { label: 'A', x: 50, y: 50, width: 80, height: 40 }, + '2': { label: 'B', x: 250, y: 180, width: 80, height: 40 }, +}; export function MultiViews() { return ( diff --git a/packages/joint-react/src/types/cell.types.ts b/packages/joint-react/src/types/cell.types.ts deleted file mode 100644 index bd3729e42..000000000 --- a/packages/joint-react/src/types/cell.types.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { dia } from '@joint/core'; -export interface CellWithId { - readonly id: dia.Cell.ID; -} diff --git a/packages/joint-react/src/types/element-types.ts b/packages/joint-react/src/types/element-types.ts index 8dcfe8d50..5b5cef451 100644 --- a/packages/joint-react/src/types/element-types.ts +++ b/packages/joint-react/src/types/element-types.ts @@ -25,10 +25,6 @@ export interface StandardShapesTypeMapper { export type StandardShapesType = keyof StandardShapesTypeMapper; export interface GraphElement extends Record { - /** - * Unique identifier of the element. - */ - id: dia.Cell.ID; /** * Ports of the element. */ diff --git a/packages/joint-react/src/types/link-types.ts b/packages/joint-react/src/types/link-types.ts index 41dc9dab2..31f3c2b10 100644 --- a/packages/joint-react/src/types/link-types.ts +++ b/packages/joint-react/src/types/link-types.ts @@ -14,10 +14,6 @@ export type StandardLinkShapesType = keyof StandardLinkShapesTypeMapper; * @see @see https://docs.jointjs.com/learn/features/shapes/links/#dialink */ export interface GraphLink extends Record { - /** - * Unique identifier of the link. - */ - readonly id: dia.Cell.ID; /** * Source element id or endpoint definition. */ diff --git a/packages/joint-react/src/types/scheduler.types.ts b/packages/joint-react/src/types/scheduler.types.ts new file mode 100644 index 000000000..39b1dcadf --- /dev/null +++ b/packages/joint-react/src/types/scheduler.types.ts @@ -0,0 +1,40 @@ +import type { dia } from '@joint/core'; +import type { GraphElement } from './element-types'; +import type { GraphLink } from './link-types'; + +/** + * Unified scheduler data structure for batching all JointJS to React updates. + * Uses Maps for efficient add/update and delete operations. + * All fields are optional - only include what needs updating. + */ +export interface GraphSchedulerData { + // Elements + readonly elementsToUpdate?: Map; + readonly elementsToDelete?: Map; + + // Links + readonly linksToUpdate?: Map; + readonly linksToDelete?: Map; + + // Ports (nested by element ID) + readonly portsToUpdate?: Map>; + readonly portsToDelete?: Map>; + + // Port Groups (nested by element ID) + readonly portGroupsToUpdate?: Map>; + readonly portGroupsToDelete?: Map>; + + // Link attributes + readonly linkAttrsToUpdate?: Map>; + + // Labels (nested by link ID) + readonly labelsToUpdate?: Map>; + readonly labelsToDelete?: Map>; + + // Views (for React paper updates) + readonly viewsToUpdate?: Map; + readonly viewsToDelete?: Map; + + // Paper update trigger + readonly shouldUpdatePaper?: boolean; +} diff --git a/packages/joint-react/src/utils/__tests__/get-cell.test.ts b/packages/joint-react/src/utils/__tests__/get-cell.test.ts index 787bd0119..2a5aa3be6 100644 --- a/packages/joint-react/src/utils/__tests__/get-cell.test.ts +++ b/packages/joint-react/src/utils/__tests__/get-cell.test.ts @@ -1,18 +1,15 @@ -import { mapLinkFromGraph } from '../cell/cell-utilities'; +import { createDefaultGraphToLinkMapper } from '../../state/graph-state-selectors'; import type { dia } from '@joint/core'; -describe('cell utilities', () => { - let mockCell: dia.Cell; +describe('graph-state-selectors link mapping', () => { + let mockCell: dia.Link; beforeEach(() => { mockCell = { id: 'mock-id', attributes: { - size: { width: 100, height: 50 }, - position: { x: 10, y: 20 }, data: { key: 'value' }, type: 'mock-type', - ports: { items: [] }, }, get: jest.fn((key) => { const mockData: Record = { @@ -21,29 +18,27 @@ describe('cell utilities', () => { z: 1, markup: '', defaultLabel: 'default-label', - ports: { items: [] }, }; return mockData[key]; }), - } as unknown as dia.Cell; + } as unknown as dia.Link; }); - describe('linkFromGraph', () => { + describe('createDefaultGraphToLinkMapper', () => { it('should extract link attributes correctly', () => { - const link = mapLinkFromGraph(mockCell); + const mapper = createDefaultGraphToLinkMapper(mockCell); + const link = mapper(); + // id is no longer part of GraphLink - it's the Record key expect(link).toMatchObject({ - id: 'mock-id', source: 'source-id', target: 'target-id', type: 'mock-type', z: 1, markup: '', defaultLabel: 'default-label', - data: { key: 'value' }, - size: { width: 100, height: 50 }, - position: { x: 10, y: 20 }, - ports: { items: [] }, + key: 'value', // data properties are spread to top level }); + expect(link).not.toHaveProperty('id'); }); }); }); diff --git a/packages/joint-react/src/utils/cell/cell-utilities.ts b/packages/joint-react/src/utils/cell/cell-utilities.ts deleted file mode 100644 index 55f39e364..000000000 --- a/packages/joint-react/src/utils/cell/cell-utilities.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { util, type dia } from '@joint/core'; -import { REACT_TYPE } from '../../models/react-element'; -import type { GraphLink } from '../../types/link-types'; -import type { GraphElement } from '../../types/element-types'; -import { getTargetOrSource } from './get-link-targe-and-source-ids'; - -export type CellOrJsonCell = dia.Cell | dia.Cell.JSON; - -/** - * Converts a link to a graph cell. - * @param link - The link to convert. - * @param graph - The graph instance. - * @returns The cell or JSON cell representation. - */ -export function mapLinkToGraph(link: GraphLink, graph: dia.Graph): CellOrJsonCell { - const source = getTargetOrSource(link.source); - const target = getTargetOrSource(link.target); - const { attrs, type = 'standard.Link', ...rest } = link; - - // Note: Accessing prototype defaults directly. Consider caching defaults for performance. - const defaults = util.result( - util.getByPath(graph.layerCollection.cellNamespace, type, '.').prototype, - 'defaults', - {} - ); - - const mergedLink = { - ...rest, - type, - attrs: util.defaultsDeep({}, attrs as never, defaults.attrs), - }; - - return { - ...mergedLink, - type: link.type ?? 'standard.Link', - source, - target, - } as dia.Cell.JSON; -} - -/** - * Maps a GraphElement to a JointJS Cell or JSON representation. - * @param element - The element to process. - * @returns A standard JointJS element or a JSON representation of the element. - * @group utils - * @description - * This function is used to process an element and convert it to a standard JointJS element if needed. - * It extracts position and size information from the element and creates the appropriate cell representation. - * @private - * @example - * ```ts - * import { mapElementToGraph } from '@joint/react'; - * - * const element = { id: '1', x: 10, y: 20, width: 100, height: 50 }; - * const processed = mapElementToGraph(element); - * ``` - */ -export function mapElementToGraph(element: T): CellOrJsonCell { - const { type = REACT_TYPE, x, y, width, height } = element; - - return { - type, - position: { x, y }, - size: { width, height }, - ...element, - } as dia.Cell.JSON; -} - -export type GraphCell = Element | GraphLink; - -/** - * Get link via cell - * @param cell - The cell to get the link from. - * @returns - The link. - * @group utils - * @private - * @description - * This function is used to get a link from a cell. - * It extracts the source, target, and attributes from the cell and returns them as a link. - * It also adds the id, isElement, isLink, type, z, markup, and defaultLabel to the link. - * @example - * ```ts - * const link = getLink(cell); - * console.log(link); - * ``` - */ -export function mapLinkFromGraph( - cell: dia.Cell -): Link { - return { - ...cell.attributes, - id: cell.id, - source: cell.get('source') as dia.Cell.ID, - target: cell.get('target') as dia.Cell.ID, - type: cell.attributes.type, - z: cell.get('z'), - markup: cell.get('markup'), - defaultLabel: cell.get('defaultLabel'), - } as Link; -} diff --git a/packages/joint-react/src/utils/cell/listen-to-cell-change.ts b/packages/joint-react/src/utils/cell/listen-to-cell-change.ts index 38813a30c..af7fc8f5f 100644 --- a/packages/joint-react/src/utils/cell/listen-to-cell-change.ts +++ b/packages/joint-react/src/utils/cell/listen-to-cell-change.ts @@ -2,10 +2,20 @@ import { mvc, type dia } from '@joint/core'; export type ChangeEvent = 'change' | 'add' | 'remove' | 'reset'; +/** + * JointJS options object passed through events. + * Contains flags like `isUpdateFromReact` to detect update sources. + */ +export interface JointJSEventOptions { + readonly isUpdateFromReact?: boolean; + readonly [key: string]: unknown; +} + interface OnChangeOptionsBase { readonly type: ChangeEvent; readonly cells?: dia.Cell[]; readonly cell?: dia.Cell; + readonly options?: JointJSEventOptions; } interface OnChangeOptionsUpdate extends OnChangeOptionsBase { readonly type: 'change' | 'add' | 'remove'; @@ -31,15 +41,26 @@ export function listenToCellChange( handleCellsChange: OnChangeHandler ): () => void { const controller = new mvc.Listener(); - controller.listenTo(graph, 'change', (cell: dia.Cell) => - handleCellsChange({ type: 'change', cell }) + controller.listenTo(graph, 'change', (cell: dia.Cell, options: JointJSEventOptions) => + handleCellsChange({ type: 'change', cell, options }) + ); + controller.listenTo( + graph, + 'add', + (cell: dia.Cell, _collection: mvc.Collection, options: JointJSEventOptions) => + handleCellsChange({ type: 'add', cell, options }) ); - controller.listenTo(graph, 'add', (cell: dia.Cell) => handleCellsChange({ type: 'add', cell })); - controller.listenTo(graph, 'remove', (cell: dia.Cell) => - handleCellsChange({ type: 'remove', cell }) + controller.listenTo( + graph, + 'remove', + (cell: dia.Cell, _collection: mvc.Collection, options: JointJSEventOptions) => + handleCellsChange({ type: 'remove', cell, options }) ); - controller.listenTo(graph, 'reset', (cells: dia.Cell[]) => - handleCellsChange({ type: 'reset', cells }) + controller.listenTo( + graph, + 'reset', + (collection: mvc.Collection, options: JointJSEventOptions) => + handleCellsChange({ type: 'reset', cells: collection.models, options }) ); return () => controller.stopListening(); diff --git a/packages/joint-react/src/utils/clear-view.ts b/packages/joint-react/src/utils/clear-view.ts index 32825043a..7a2579145 100644 --- a/packages/joint-react/src/utils/clear-view.ts +++ b/packages/joint-react/src/utils/clear-view.ts @@ -4,9 +4,7 @@ interface Options { readonly graph: dia.Graph; readonly paper: dia.Paper; readonly cellId: dia.Cell.ID; - readonly onValidateLink?: (link: dia.Link) => boolean; } -const DEFAULT_ON_VALIDATE_LINK = () => true; /** * Clear the view of the cell and the links connected to it. @@ -21,7 +19,7 @@ const DEFAULT_ON_VALIDATE_LINK = () => true; * @param options - The options for the clear view. */ export function clearView(options: Options) { - const { graph, paper, cellId, onValidateLink = DEFAULT_ON_VALIDATE_LINK } = options; + const { graph, paper, cellId } = options; const elementView = paper.findViewByModel(cellId); elementView.cleanNodesCache(); for (const link of graph.getConnectedLinks(elementView.model)) { @@ -32,11 +30,6 @@ export function clearView(options: Options) { continue; } - const isValid = onValidateLink(link); - if (!isValid) { - continue; - } - const linkView = link.findView(paper); // @ts-expect-error we use private jointjs api method, it throw error here. linkView._sourceMagnet = null; diff --git a/packages/joint-react/src/utils/is.ts b/packages/joint-react/src/utils/is.ts index 0335d551d..f7fff5df4 100644 --- a/packages/joint-react/src/utils/is.ts +++ b/packages/joint-react/src/utils/is.ts @@ -2,7 +2,12 @@ import { dia, util } from '@joint/core'; import type { GraphElement } from '../types/element-types'; import type { FunctionComponent, JSX } from 'react'; -import type { GraphCell } from './cell/cell-utilities'; +import type { GraphLink } from '../types/link-types'; + +/** + * Represents a cell in the graph (either element or link). + */ +export type GraphCell = Element | GraphLink; export type Setter = (item: Value) => Value; diff --git a/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx b/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx index 3b4737e4a..1bfdf9b63 100644 --- a/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx +++ b/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx @@ -1,4 +1,4 @@ -/* eslint-disable react-perf/jsx-no-new-array-as-prop */ + /* eslint-disable react-perf/jsx-no-new-object-as-prop */ import { dia } from '@joint/core'; import '../../stories/examples/index.css'; @@ -40,18 +40,23 @@ const CustomRect = dia.Element.define( } ); -const initialElements = [ - { +const initialElements: Record = { + 'rect1': { type: 'CustomRect', id: 'rect1', x: 80, y: 80, }, -]; +}; function App() { return ( - + ); diff --git a/packages/joint-react/src/utils/scheduler-old.ts b/packages/joint-react/src/utils/scheduler-old.ts deleted file mode 100644 index 5ec990378..000000000 --- a/packages/joint-react/src/utils/scheduler-old.ts +++ /dev/null @@ -1,57 +0,0 @@ -// eslint-disable-next-line camelcase -import { unstable_getCurrentPriorityLevel, unstable_scheduleCallback } from 'scheduler'; -/** - * Creates a debounced function that uses React's internal scheduler for timing. - * Multiple calls within the same synchronous execution cycle are batched into a single - * execution in the next available event loop tick. All callbacks stored by id will be - * executed when the scheduled work flushes. - * @param userCallback The function to be debounced and scheduled. - * @param priorityLevel The priority level to run the task at. - * @returns A function you call to schedule your work with an optional callback and id. - */ -export function createScheduler(userCallback: () => void, priorityLevel?: number) { - let callbackId: unknown | null = null; - const callbacks = new Map void>(); - - const effectivePriority = - priorityLevel === undefined ? unstable_getCurrentPriorityLevel() : priorityLevel; - - /** - * The actual function that processes the batched callbacks. - * This runs asynchronously via the scheduler. - */ - const flushScheduledWork = () => { - callbackId = null; - - // Collect all ids before clearing - - // Execute all stored callbacks with their respective ids - for (const [id, callback] of callbacks) { - callback(id); - } - - // Execute the main callback for each id after stored callbacks - userCallback(); - - // Clear all stored callbacks after execution - callbacks.clear(); - }; - - /** - * This is the function the user calls to schedule a task. - * It stores the callback with its id and ensures the flush function is scheduled once. - * @param id Optional id string to pass to the callback. - * @param callback Optional callback function that receives an id parameter. - */ - return (id?: string, callback?: (id: string) => void) => { - if (id !== undefined && callback !== undefined) { - // Store callback in map with id as key - callbacks.set(id, callback); - } - - if (callbackId === null) { - // Schedule the flush function if it hasn't been scheduled already - callbackId = unstable_scheduleCallback(effectivePriority, flushScheduledWork); - } - }; -} diff --git a/packages/joint-react/src/utils/scheduler.ts b/packages/joint-react/src/utils/scheduler.ts index 5156ff45d..1fded1ac0 100644 --- a/packages/joint-react/src/utils/scheduler.ts +++ b/packages/joint-react/src/utils/scheduler.ts @@ -1,7 +1,7 @@ import { unstable_getCurrentPriorityLevel as getCurrentPriorityLevel, unstable_scheduleCallback as scheduleCallback, -} from "scheduler"; +} from 'scheduler'; /** * Options for creating a graph updates scheduler. @@ -28,20 +28,22 @@ export interface GraphUpdatesSchedulerOptions { * @example * ```ts * interface MyData { - * elements?: string[]; - * links?: string[]; + * elementsToUpdate?: Map; + * elementsToDelete?: Map; * } * - * const scheduler = new GraphUpdatesScheduler({ + * const scheduler = new Scheduler({ * onFlush: (data) => { - * console.log(data.elements, data.links); + * console.log(data.elementsToUpdate, data.elementsToDelete); * }, * }); * * // Multiple calls are batched - * scheduler.scheduleData((prev) => ({ ...prev, elements: ['el1'] })); - * scheduler.scheduleData((prev) => ({ ...prev, links: ['link1'] })); - * // onFlush called once with combined result: { elements: ['el1'], links: ['link1'] } + * const updateMap = new Map([['el1', { id: 'el1', label: 'Element 1' }]]); + * scheduler.scheduleData((prev) => ({ ...prev, elementsToUpdate: updateMap })); + * const deleteMap = new Map([['el2', true as const]]); + * scheduler.scheduleData((prev) => ({ ...prev, elementsToDelete: deleteMap })); + * // onFlush called once with combined result * ``` */ export class Scheduler { @@ -66,10 +68,7 @@ export class Scheduler { this.currentData = updater(this.currentData); if (this.callbackId === null) { - this.callbackId = scheduleCallback( - this.effectivePriority, - this.flushScheduledWork, - ); + this.callbackId = scheduleCallback(this.effectivePriority, this.flushScheduledWork); } }; diff --git a/packages/joint-react/src/utils/test-wrappers.tsx b/packages/joint-react/src/utils/test-wrappers.tsx index 1070407c2..00bfd0f38 100644 --- a/packages/joint-react/src/utils/test-wrappers.tsx +++ b/packages/joint-react/src/utils/test-wrappers.tsx @@ -58,20 +58,20 @@ export function paperRenderElementWrapper(options: Options): React.JSXElementCon export const simpleRenderElementWrapper = paperRenderElementWrapper({ graphProviderProps: { - elements: [ - { + elements: { + '1': { id: '1', width: 97, height: 99, }, - ], - links: [ - { + }, + links: { + '3': { id: '3', source: '1', target: '2', }, - ], + }, }, }); diff --git a/yarn.lock b/yarn.lock index ff9a99992..62415cb4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2751,6 +2751,15 @@ __metadata: languageName: node linkType: hard +"@jest/create-cache-key-function@npm:^30.0.0": + version: 30.2.0 + resolution: "@jest/create-cache-key-function@npm:30.2.0" + dependencies: + "@jest/types": "npm:30.2.0" + checksum: 10/7a2dd0efe747c1b2e61825e51ace11e42f278203fae37a3b9462c8d2132394978682ed7094f5ce3d9f5a9e5f2855e6d1d933e5f3ac5165b127c36591f3d98d85 + languageName: node + linkType: hard + "@jest/diff-sequences@npm:30.0.1": version: 30.0.1 resolution: "@jest/diff-sequences@npm:30.0.1" @@ -3477,6 +3486,8 @@ __metadata: "@storybook/react": "npm:8.6.12" "@storybook/react-vite": "npm:8.6.12" "@storybook/test": "npm:8.6.12" + "@swc/core": "npm:^1.15.11" + "@swc/jest": "npm:^0.2.39" "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.0.1" "@testing-library/react-hooks": "npm:^8.0.1" @@ -3511,7 +3522,6 @@ __metadata: storybook: "npm:^8.6.14" storybook-addon-performance: "npm:0.17.3" storybook-multilevel-sort: "npm:2.1.0" - ts-jest: "npm:^29.2.5" ts-node: "npm:^10.9.2" typedoc: "npm:^0.28.5" typedoc-github-theme: "npm:^0.3.0" @@ -5575,6 +5585,151 @@ __metadata: languageName: node linkType: hard +"@swc/core-darwin-arm64@npm:1.15.11": + version: 1.15.11 + resolution: "@swc/core-darwin-arm64@npm:1.15.11" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@swc/core-darwin-x64@npm:1.15.11": + version: 1.15.11 + resolution: "@swc/core-darwin-x64@npm:1.15.11" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@swc/core-linux-arm-gnueabihf@npm:1.15.11": + version: 1.15.11 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.15.11" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@swc/core-linux-arm64-gnu@npm:1.15.11": + version: 1.15.11 + resolution: "@swc/core-linux-arm64-gnu@npm:1.15.11" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@swc/core-linux-arm64-musl@npm:1.15.11": + version: 1.15.11 + resolution: "@swc/core-linux-arm64-musl@npm:1.15.11" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@swc/core-linux-x64-gnu@npm:1.15.11": + version: 1.15.11 + resolution: "@swc/core-linux-x64-gnu@npm:1.15.11" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@swc/core-linux-x64-musl@npm:1.15.11": + version: 1.15.11 + resolution: "@swc/core-linux-x64-musl@npm:1.15.11" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@swc/core-win32-arm64-msvc@npm:1.15.11": + version: 1.15.11 + resolution: "@swc/core-win32-arm64-msvc@npm:1.15.11" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@swc/core-win32-ia32-msvc@npm:1.15.11": + version: 1.15.11 + resolution: "@swc/core-win32-ia32-msvc@npm:1.15.11" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@swc/core-win32-x64-msvc@npm:1.15.11": + version: 1.15.11 + resolution: "@swc/core-win32-x64-msvc@npm:1.15.11" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@swc/core@npm:^1.15.11": + version: 1.15.11 + resolution: "@swc/core@npm:1.15.11" + dependencies: + "@swc/core-darwin-arm64": "npm:1.15.11" + "@swc/core-darwin-x64": "npm:1.15.11" + "@swc/core-linux-arm-gnueabihf": "npm:1.15.11" + "@swc/core-linux-arm64-gnu": "npm:1.15.11" + "@swc/core-linux-arm64-musl": "npm:1.15.11" + "@swc/core-linux-x64-gnu": "npm:1.15.11" + "@swc/core-linux-x64-musl": "npm:1.15.11" + "@swc/core-win32-arm64-msvc": "npm:1.15.11" + "@swc/core-win32-ia32-msvc": "npm:1.15.11" + "@swc/core-win32-x64-msvc": "npm:1.15.11" + "@swc/counter": "npm:^0.1.3" + "@swc/types": "npm:^0.1.25" + peerDependencies: + "@swc/helpers": ">=0.5.17" + dependenciesMeta: + "@swc/core-darwin-arm64": + optional: true + "@swc/core-darwin-x64": + optional: true + "@swc/core-linux-arm-gnueabihf": + optional: true + "@swc/core-linux-arm64-gnu": + optional: true + "@swc/core-linux-arm64-musl": + optional: true + "@swc/core-linux-x64-gnu": + optional: true + "@swc/core-linux-x64-musl": + optional: true + "@swc/core-win32-arm64-msvc": + optional: true + "@swc/core-win32-ia32-msvc": + optional: true + "@swc/core-win32-x64-msvc": + optional: true + peerDependenciesMeta: + "@swc/helpers": + optional: true + checksum: 10/2ee702f6ee39fc68f1e4d03a19191eaa3762d54ab917d5617741196bbe3beba9fb50b1e878af2735f8a42ecdef3632f44acc090611ebf01a0df4dc533a71f5d2 + languageName: node + linkType: hard + +"@swc/counter@npm:^0.1.3": + version: 0.1.3 + resolution: "@swc/counter@npm:0.1.3" + checksum: 10/df8f9cfba9904d3d60f511664c70d23bb323b3a0803ec9890f60133954173047ba9bdeabce28cd70ba89ccd3fd6c71c7b0bd58be85f611e1ffbe5d5c18616598 + languageName: node + linkType: hard + +"@swc/jest@npm:^0.2.39": + version: 0.2.39 + resolution: "@swc/jest@npm:0.2.39" + dependencies: + "@jest/create-cache-key-function": "npm:^30.0.0" + "@swc/counter": "npm:^0.1.3" + jsonc-parser: "npm:^3.2.0" + peerDependencies: + "@swc/core": "*" + checksum: 10/a2b7ed6fbb908867e673d1bbff9efde7eee225a57ad75735216ce2005e40c5cfb92285bd807d2058f1c0317e3d48ed71f5577fe85b28bebc80c1bc2c3a03306e + languageName: node + linkType: hard + +"@swc/types@npm:^0.1.25": + version: 0.1.25 + resolution: "@swc/types@npm:0.1.25" + dependencies: + "@swc/counter": "npm:^0.1.3" + checksum: 10/f6741450224892d12df43e5ca7f3cc0287df644dcd672626eb0cc2a3a8e3e875f4b29eb11336f37c7240cf6e010ba59eb3a79f4fb8bee5cbd168dfc1326ff369 + languageName: node + linkType: hard + "@szmarczak/http-timer@npm:^4.0.5": version: 4.0.6 resolution: "@szmarczak/http-timer@npm:4.0.6" @@ -8956,15 +9111,6 @@ __metadata: languageName: node linkType: hard -"bs-logger@npm:^0.2.6": - version: 0.2.6 - resolution: "bs-logger@npm:0.2.6" - dependencies: - fast-json-stable-stringify: "npm:2.x" - checksum: 10/e6d3ff82698bb3f20ce64fb85355c5716a3cf267f3977abe93bf9c32a2e46186b253f48a028ae5b96ab42bacd2c826766d9ae8cf6892f9b944656be9113cf212 - languageName: node - linkType: hard - "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -12884,7 +13030,7 @@ __metadata: languageName: node linkType: hard -"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": +"fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" checksum: 10/2c20055c1fa43c922428f16ca8bb29f2807de63e5c851f665f7ac9790176c01c3b40335257736b299764a8d383388dabc73c8083b8e1bc3d99f0a941444ec60e @@ -14300,24 +14446,6 @@ __metadata: languageName: node linkType: hard -"handlebars@npm:^4.7.8": - version: 4.7.8 - resolution: "handlebars@npm:4.7.8" - dependencies: - minimist: "npm:^1.2.5" - neo-async: "npm:^2.6.2" - source-map: "npm:^0.6.1" - uglify-js: "npm:^3.1.4" - wordwrap: "npm:^1.0.0" - dependenciesMeta: - uglify-js: - optional: true - bin: - handlebars: bin/handlebars - checksum: 10/bd528f4dd150adf67f3f857118ef0fa43ff79a153b1d943fa0a770f2599e38b25a7a0dbac1a3611a4ec86970fd2325a81310fb788b5c892308c9f8743bd02e11 - languageName: node - linkType: hard - "happy-dom@npm:^8.1.0": version: 8.9.0 resolution: "happy-dom@npm:8.9.0" @@ -16595,6 +16723,13 @@ __metadata: languageName: node linkType: hard +"jsonc-parser@npm:^3.2.0": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10/9b0dc391f20b47378f843ef1e877e73ec652a5bdc3c5fa1f36af0f119a55091d147a86c1ee86a232296f55c929bba174538c2bf0312610e0817a22de131cc3f4 + languageName: node + linkType: hard + "jsonfile@npm:^4.0.0": version: 4.0.0 resolution: "jsonfile@npm:4.0.0" @@ -17080,13 +17215,6 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:^4.1.2": - version: 4.1.2 - resolution: "lodash.memoize@npm:4.1.2" - checksum: 10/192b2168f310c86f303580b53acf81ab029761b9bd9caa9506a019ffea5f3363ea98d7e39e7e11e6b9917066c9d36a09a11f6fe16f812326390d8f3a54a1a6da - languageName: node - linkType: hard - "lodash.memoize@npm:~3.0.3": version: 3.0.4 resolution: "lodash.memoize@npm:3.0.4" @@ -17328,7 +17456,7 @@ __metadata: languageName: node linkType: hard -"make-error@npm:^1.1.1, make-error@npm:^1.3.6": +"make-error@npm:^1.1.1": version: 1.3.6 resolution: "make-error@npm:1.3.6" checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 @@ -23548,46 +23676,6 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.2.5": - version: 29.4.6 - resolution: "ts-jest@npm:29.4.6" - dependencies: - bs-logger: "npm:^0.2.6" - fast-json-stable-stringify: "npm:^2.1.0" - handlebars: "npm:^4.7.8" - json5: "npm:^2.2.3" - lodash.memoize: "npm:^4.1.2" - make-error: "npm:^1.3.6" - semver: "npm:^7.7.3" - type-fest: "npm:^4.41.0" - yargs-parser: "npm:^21.1.1" - peerDependencies: - "@babel/core": ">=7.0.0-beta.0 <8" - "@jest/transform": ^29.0.0 || ^30.0.0 - "@jest/types": ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: ">=4.3 <6" - peerDependenciesMeta: - "@babel/core": - optional: true - "@jest/transform": - optional: true - "@jest/types": - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true - bin: - ts-jest: cli.js - checksum: 10/e0ff9e13f684166d5331808b288043b8054f49a1c2970480a92ba3caec8d0ff20edd092f2a4e7a3ad8fcb9ba4d674bee10ec7ee75046d8066bbe43a7d16cf72e - languageName: node - linkType: hard - "ts-loader@npm:^9.2.5": version: 9.5.4 resolution: "ts-loader@npm:9.5.4" @@ -23776,13 +23864,6 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^4.41.0": - version: 4.41.0 - resolution: "type-fest@npm:4.41.0" - checksum: 10/617ace794ac0893c2986912d28b3065ad1afb484cad59297835a0807dc63286c39e8675d65f7de08fafa339afcb8fe06a36e9a188b9857756ae1e92ee8bda212 - languageName: node - linkType: hard - "type-is@npm:~1.6.18": version: 1.6.18 resolution: "type-is@npm:1.6.18" @@ -24028,7 +24109,7 @@ __metadata: languageName: node linkType: hard -"uglify-js@npm:^3.1.4, uglify-js@npm:^3.5.0": +"uglify-js@npm:^3.5.0": version: 3.19.3 resolution: "uglify-js@npm:3.19.3" bin: @@ -25610,13 +25691,6 @@ __metadata: languageName: node linkType: hard -"wordwrap@npm:^1.0.0": - version: 1.0.0 - resolution: "wordwrap@npm:1.0.0" - checksum: 10/497d40beb2bdb08e6d38754faa17ce20b0bf1306327f80cb777927edb23f461ee1f6bc659b3c3c93f26b08e1cf4b46acc5bae8fda1f0be3b5ab9a1a0211034cd - languageName: node - linkType: hard - "workerpool@npm:^6.5.1": version: 6.5.1 resolution: "workerpool@npm:6.5.1" From df4a3a6daf52bd751006c41dbd0f3dd3f771dde3 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 30 Jan 2026 15:37:31 +0700 Subject: [PATCH 3/5] refactor: remove unnecessary 'id' properties from elements and links in various components and tests - Updated GraphProvider stories to remove 'id' from elements and links. - Cleaned up highlighter tests by removing 'id' from element definitions. - Adjusted stroke highlighter tests to eliminate 'id' from elements. - Modified paper tests to remove 'id' from elements and links. - Refactored port tests to exclude 'id' from elements. - Updated hooks tests to remove 'id' from elements. - Cleaned up model tests by removing 'id' from ReactElement and ReactLink instances. - Adjusted example stories to remove 'id' from elements and links. --- .../decorators/with-simple-data.tsx | 35 ++++++----- .../decorators/with-strict-mode.tsx | 4 +- packages/joint-react/README.md | 23 +++---- packages/joint-react/eslint.config.mjs | 18 +++++- .../graph/graph-provider.stories.tsx | 6 +- .../__tests__/highlighter-cleanup.test.tsx | 1 - .../highlighters/__tests__/stroke.test.tsx | 1 - .../paper-html-overlay-links.test.tsx | 9 ++- .../components/paper/__tests__/paper.test.tsx | 12 ++-- .../src/components/paper/paper.stories.tsx | 8 +-- .../port/__tests__/port-group.test.tsx | 1 - .../port/__tests__/port-item.test.tsx | 1 - .../components/port/__tests__/port.test.tsx | 11 +--- .../components/port/port-group.stories.tsx | 5 -- .../src/components/port/port-item.stories.tsx | 5 -- .../src/hooks/__tests__/use-cell-id.test.tsx | 1 - .../src/hooks/__tests__/use-element.test.tsx | 2 - .../src/hooks/__tests__/use-elements.test.ts | 3 - .../src/hooks/__tests__/use-graph.test.ts | 1 - .../src/hooks/__tests__/use-links.test.ts | 6 +- .../__tests__/use-paper-context.test.tsx | 1 - .../src/hooks/use-cell-actions.stories.tsx | 23 ++++--- .../models/__tests__/react-element.test.ts | 8 +-- .../src/models/__tests__/react-link.test.ts | 10 +-- .../src/models/__tests__/react-paper.test.ts | 63 +++++++------------ .../store/create-elements-size-observer.ts | 4 +- .../with-node-update/code-with-color.tsx | 12 ++-- .../examples/with-node-update/code.tsx | 13 ++-- .../examples/with-rotable-node/code.tsx | 14 ++--- .../utils/joint-jsx/jsx-to-markup.stories.tsx | 2 - .../joint-react/src/utils/test-wrappers.tsx | 2 - packages/joint-react/tsconfig.json | 14 ++--- 32 files changed, 137 insertions(+), 182 deletions(-) diff --git a/packages/joint-react/.storybook/decorators/with-simple-data.tsx b/packages/joint-react/.storybook/decorators/with-simple-data.tsx index 684ee4ae2..2849d36f5 100644 --- a/packages/joint-react/.storybook/decorators/with-simple-data.tsx +++ b/packages/joint-react/.storybook/decorators/with-simple-data.tsx @@ -4,7 +4,7 @@ // @ts-expect-error do not provide typings. import JsonViewer from '@andypf/json-viewer/dist/esm/react/JsonViewer'; import { useCallback, useRef, type HTMLProps, type JSX, type PropsWithChildren } from 'react'; -import { GraphProvider, useNodeSize, type GraphLink } from '@joint/react'; +import { GraphProvider, useCellId, useNodeSize, type GraphLink } from '@joint/react'; import { PAPER_CLASSNAME, PRIMARY } from '../theme'; import type { PartialStoryFn, StoryContext } from 'storybook/internal/types'; import { Paper } from '../../src/components/paper/paper'; @@ -12,19 +12,20 @@ import { Paper } from '../../src/components/paper/paper'; export type StoryFunction = PartialStoryFn; export type StoryCtx = StoryContext; -export const testElements: Record = { +export const testElements: Record< + string, + { + label: string; + color: string; + x: number; + y: number; + width: number; + height: number; + hoverColor: string; + angle: number; + } +> = { '1': { - id: '1', label: 'Node 1', color: PRIMARY, x: 100, @@ -35,7 +36,6 @@ export const testElements: Record = { 'l-1': { - id: 'l-1', source: '1', target: '2', attrs: { @@ -138,7 +137,11 @@ export function SimpleRenderLinkDecorator(Story: StoryFunction, { args }: StoryC {id}} + renderElement={() => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const id = useCellId(); + return {id}; + }} /> ); } diff --git a/packages/joint-react/.storybook/decorators/with-strict-mode.tsx b/packages/joint-react/.storybook/decorators/with-strict-mode.tsx index 02068bd7f..890501326 100644 --- a/packages/joint-react/.storybook/decorators/with-strict-mode.tsx +++ b/packages/joint-react/.storybook/decorators/with-strict-mode.tsx @@ -1,6 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import React from 'react'; - +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function withStrictMode(Story: any) { return ( // diff --git a/packages/joint-react/README.md b/packages/joint-react/README.md index 7b767bccf..4f2a49d03 100644 --- a/packages/joint-react/README.md +++ b/packages/joint-react/README.md @@ -41,8 +41,7 @@ bun add @joint/react ## 🧭 Core Ideas -- **Elements (nodes)** and **links (edges)** are plain objects. -- Define **`id` explicitly** and mark it as a literal (`'foo' as const`) so TypeScript keeps it precise. +- **Elements (nodes)** and **links (edges)** are plain objects stored in a `Record` where the key is the ID. - The **`GraphProvider`** component provides the graph context; **`Paper`** renders it. - Use hooks like `useElements`, `useLinks`, `useGraph`, `usePaper` for reading/updating state. @@ -50,19 +49,19 @@ bun add @joint/react ## πŸš€ Quick Start (TypeScript) -`id` must be present for every element and link. +Elements and links are stored in a `Record` where the key is the ID. ```tsx import React from 'react' import { GraphProvider } from '@joint/react' const elements = { - 'node1': { id: 'node1', label: 'Start', x: 100, y: 50, width: 120, height: 60 }, - 'node2': { id: 'node2', label: 'End', x: 100, y: 200, width: 120, height: 60 }, + 'node1': { label: 'Start', x: 100, y: 50, width: 120, height: 60 }, + 'node2': { label: 'End', x: 100, y: 200, width: 120, height: 60 }, } as const const links = { - 'link1': { id: 'link1', source: 'node1', target: 'node2' }, + 'link1': { source: 'node1', target: 'node2' }, } as const // Narrow element type straight from the record: @@ -103,12 +102,12 @@ import React from 'react' import { GraphProvider } from '@joint/react' const elements = { - 'a': { id: 'a' as const, label: 'A', x: 40, y: 60, width: 80, height: 40 }, - 'b': { id: 'b' as const, label: 'B', x: 260, y: 180, width: 80, height: 40 }, + 'a': { label: 'A', x: 40, y: 60, width: 80, height: 40 }, + 'b': { label: 'B', x: 260, y: 180, width: 80, height: 40 }, } as const const links = { - 'a-b': { id: 'a-b' as const, source: 'a', target: 'b' }, + 'a-b': { source: 'a', target: 'b' }, } as const export function MultiView() { @@ -169,7 +168,7 @@ import React, { useState } from 'react' import { GraphProvider } from '@joint/react' const initialElements = { - 'n1': { id: 'n1' as const, label: 'Item', x: 60, y: 60, width: 100, height: 40 }, + 'n1': { label: 'Item', x: 60, y: 60, width: 100, height: 40 }, } as const const initialLinks = {} as const @@ -231,8 +230,7 @@ function MyComponent() { const { set, remove } = useCellActions() const addNode = () => { - set({ - id: 'new-node', + set('new-node', { x: 100, y: 100, width: 120, @@ -288,7 +286,6 @@ export function FitOnMount() { ## 🧠 Best Practices -- **Define ids as literals**: `id: 'node1' as const` β€” enables exact typing and prevents mismatches. - **Type elements from data**: `type Element = typeof elements[keyof typeof elements]` β€” reuse data as your source of truth. - **Memoize renderers & handlers**: `useCallback` to minimize re-renders. - **Keep overlay HTML lightweight**: Prefer simple layout; avoid heavy transforms/animations in `` (Safari can be picky). diff --git a/packages/joint-react/eslint.config.mjs b/packages/joint-react/eslint.config.mjs index 8fabb3401..c822b0ba6 100644 --- a/packages/joint-react/eslint.config.mjs +++ b/packages/joint-react/eslint.config.mjs @@ -2,7 +2,19 @@ import { tsConfig, jsConfig, reactTsConfig } from '@joint/eslint-config'; import { defineConfig } from 'eslint/config'; export default defineConfig([ - ...jsConfig, - ...tsConfig, - ...reactTsConfig, + { + ignores: ['**/*.snap'], + }, + { + files: ['src/**/*.{js,jsx,ts,tsx}', '.storybook/**/*.{js,jsx,ts,tsx}'], + }, + ...jsConfig, + ...tsConfig, + ...reactTsConfig, + { + files: ['src/stories/tutorials/step-by-step/code-controlled-mode-jotai.tsx'], + rules: { + 'jsdoc/escape-inline-tags': ['warn', { allowedInlineTags: ['joint'] }], + }, + }, ]); diff --git a/packages/joint-react/src/components/graph/graph-provider.stories.tsx b/packages/joint-react/src/components/graph/graph-provider.stories.tsx index 8e7fc33d6..9071b12ba 100644 --- a/packages/joint-react/src/components/graph/graph-provider.stories.tsx +++ b/packages/joint-react/src/components/graph/graph-provider.stories.tsx @@ -36,12 +36,12 @@ The **GraphProvider** component provides a shared Graph context for all its desc import { GraphProvider, Paper } from '@joint/react'; const elements = { - '1': { id: '1', x: 100, y: 100, width: 100, height: 50 }, - '2': { id: '2', x: 250, y: 200, width: 100, height: 50 }, + '1': { x: 100, y: 100, width: 100, height: 50 }, + '2': { x: 250, y: 200, width: 100, height: 50 }, }; const links = { - 'l1': { id: 'l1', source: '1', target: '2' }, + 'l1': { source: '1', target: '2' }, }; function MyDiagram() { diff --git a/packages/joint-react/src/components/highlighters/__tests__/highlighter-cleanup.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/highlighter-cleanup.test.tsx index a87012761..526996c5a 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/highlighter-cleanup.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/highlighter-cleanup.test.tsx @@ -16,7 +16,6 @@ describe('Highlighter cleanup', () => { graph, elements: { 'element-1': { - id: 'element-1', x: 0, y: 0, width: 100, diff --git a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx index 7ffc6ca0a..43bbaeeba 100644 --- a/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx +++ b/packages/joint-react/src/components/highlighters/__tests__/stroke.test.tsx @@ -7,7 +7,6 @@ describe('Stroke highlighter', () => { graphProviderProps: { elements: { '1': { - id: '1', width: 100, height: 100, }, diff --git a/packages/joint-react/src/components/paper/__tests__/paper-html-overlay-links.test.tsx b/packages/joint-react/src/components/paper/__tests__/paper-html-overlay-links.test.tsx index 88c2f7d12..4b0f9179d 100644 --- a/packages/joint-react/src/components/paper/__tests__/paper-html-overlay-links.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/paper-html-overlay-links.test.tsx @@ -10,13 +10,12 @@ interface TestElement extends GraphElement { } const elements: Record = { - '1': { id: '1', label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, - '2': { id: '2', label: 'World', x: 100, y: 200, width: 100, height: 50 }, + '1': { label: 'Hello', x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'World', x: 100, y: 200, width: 100, height: 50 }, }; const links: Record = { 'link-1': { - id: 'link-1', source: '1', target: '2', }, @@ -107,8 +106,8 @@ describe('Paper with useHTMLOverlay and links', () => { } const initialElements: Record = { - '1': { id: '1', label: 'Element1', x: 100, y: 0, width: 100, height: 50 }, - '2': { id: '2', label: 'Element2', x: 100, y: 200, width: 100, height: 50 }, + '1': { label: 'Element1', x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'Element2', x: 100, y: 200, width: 100, height: 50 }, }; const { container } = render( diff --git a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx index 440fc3b6a..bd16fce35 100644 --- a/packages/joint-react/src/components/paper/__tests__/paper.test.tsx +++ b/packages/joint-react/src/components/paper/__tests__/paper.test.tsx @@ -109,9 +109,9 @@ describe('Paper Component', () => { it('calls onElementsSizeChange when element sizes change', async () => { const onElementsSizeChangeMock = jest.fn(); - const updatedElements: Record = { - '1': { id: '1', label: 'Node 1', width: 100, height: 50 }, - '2': { id: '2', label: 'Node 2', width: 150, height: 75 }, + const updatedElements: Record = { + '1': { label: 'Node 1', width: 100, height: 50 }, + '2': { label: 'Node 2', width: 150, height: 75 }, }; const { rerender } = render( @@ -332,9 +332,9 @@ describe('Paper Component', () => { it('should set elements and positions via react state, when change it via paper api', async () => { // Create elements with initial x/y so they can be synced back - const elementsWithPosition: Record = { - '1': { id: '1', label: 'Node 1', x: 0, y: 0, width: 10, height: 10 }, - '2': { id: '2', label: 'Node 2', x: 0, y: 0, width: 10, height: 10 }, + const elementsWithPosition: Record = { + '1': { label: 'Node 1', x: 0, y: 0, width: 10, height: 10 }, + '2': { label: 'Node 2', x: 0, y: 0, width: 10, height: 10 }, }; // eslint-disable-next-line unicorn/consistent-function-scoping function UpdatePosition() { diff --git a/packages/joint-react/src/components/paper/paper.stories.tsx b/packages/joint-react/src/components/paper/paper.stories.tsx index 6f6245287..35c521355 100644 --- a/packages/joint-react/src/components/paper/paper.stories.tsx +++ b/packages/joint-react/src/components/paper/paper.stories.tsx @@ -17,6 +17,7 @@ import { getAPILink } from '../../stories/utils/get-api-documentation-link'; import { makeRootDocumentation } from '../../stories/utils/make-story'; import { jsx } from '../../utils/joint-jsx/jsx-to-markup'; import { useCellActions } from '../../hooks/use-cell-actions'; +import { useCellId } from '../../hooks/use-cell-id'; import { Paper } from './paper'; import type { RenderElement } from './paper.types'; import type { GraphElement } from '../../types/element-types'; @@ -345,7 +346,8 @@ export const WithOnClickColorChange: Story = { }, }, render: () => { - const renderElement: RenderElement = ({ width, height, hoverColor, id }) => { + const renderElement: RenderElement = ({ width, height, hoverColor }) => { + const id = useCellId(); const { set } = useCellActions(); return (
{ graph, elements: { 'element-1': { - id: 'element-1', x: 0, y: 0, width: 100, diff --git a/packages/joint-react/src/components/port/__tests__/port-item.test.tsx b/packages/joint-react/src/components/port/__tests__/port-item.test.tsx index 958a68a57..c3910b0a7 100644 --- a/packages/joint-react/src/components/port/__tests__/port-item.test.tsx +++ b/packages/joint-react/src/components/port/__tests__/port-item.test.tsx @@ -24,7 +24,6 @@ describe('PortItem cleanup', () => { graph, elements: { 'element-1': { - id: 'element-1', x: 0, y: 0, width: 100, diff --git a/packages/joint-react/src/components/port/__tests__/port.test.tsx b/packages/joint-react/src/components/port/__tests__/port.test.tsx index 6c28f5b27..c9dc1a52e 100644 --- a/packages/joint-react/src/components/port/__tests__/port.test.tsx +++ b/packages/joint-react/src/components/port/__tests__/port.test.tsx @@ -20,7 +20,6 @@ describe('port', () => { graph, elements: { 'element-1': { - id: 'element-1', width: 100, height: 100, }, @@ -53,9 +52,7 @@ describe('port', () => { graphProviderProps: { graph, elements: { - 'element-1': { - id: 'element-1', - }, + 'element-1': {}, }, }, }); @@ -92,9 +89,7 @@ describe('port', () => { graphProviderProps: { graph, elements: { - 'element-1': { - id: 'element-1', - }, + 'element-1': {}, }, }, }); @@ -158,7 +153,6 @@ describe('port', () => { graph, elements: { 'element-1': { - id: 'element-1', width: 200, height: 100, }, @@ -236,7 +230,6 @@ describe('port', () => { graph, elements: { 'element-1': { - id: 'element-1', width: 200, height: 100, x: 0, diff --git a/packages/joint-react/src/components/port/port-group.stories.tsx b/packages/joint-react/src/components/port/port-group.stories.tsx index 7d8e84d2b..c70d007a9 100644 --- a/packages/joint-react/src/components/port/port-group.stories.tsx +++ b/packages/joint-react/src/components/port/port-group.stories.tsx @@ -11,21 +11,18 @@ import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story import { Paper } from '../paper/paper'; const initialElements: Record = { '1': { - id: '1', x: 100, y: 20, width: 100, height: 50, }, '2': { - id: '2', x: 200, y: 250, width: 100, @@ -34,13 +31,11 @@ const initialElements: Record = { 'e1-2': { - id: 'e1-2', target: { id: '2', port: 'port-one', diff --git a/packages/joint-react/src/components/port/port-item.stories.tsx b/packages/joint-react/src/components/port/port-item.stories.tsx index 3e51c1bd7..6ff6e2e16 100644 --- a/packages/joint-react/src/components/port/port-item.stories.tsx +++ b/packages/joint-react/src/components/port/port-item.stories.tsx @@ -10,30 +10,25 @@ import { makeRootDocumentation, makeStory } from '../../stories/utils/make-story import { Paper } from '../paper/paper'; const initialElements: Record = { '1': { - id: '1', x: 100, y: 20, }, '2': { - id: '2', x: 200, y: 250, }, }; const initialLinks: Record = { 'link-1': { - id: 'link-1', source: { id: '1', port: 'port-one', diff --git a/packages/joint-react/src/hooks/__tests__/use-cell-id.test.tsx b/packages/joint-react/src/hooks/__tests__/use-cell-id.test.tsx index 2a82252ac..c2c974503 100644 --- a/packages/joint-react/src/hooks/__tests__/use-cell-id.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-cell-id.test.tsx @@ -8,7 +8,6 @@ describe('use-cell-id', () => { graphProviderProps: { elements: { 'test-cell-id': { - id: 'test-cell-id', width: 100, height: 100, }, diff --git a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx index 8ab1b24ea..dce81f895 100644 --- a/packages/joint-react/src/hooks/__tests__/use-element.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-element.test.tsx @@ -7,7 +7,6 @@ describe('use-element', () => { graphProviderProps: { elements: { '1': { - id: '1', width: 100, height: 100, x: 10, @@ -32,7 +31,6 @@ describe('use-element', () => { await waitFor(() => { expect(result.current).toBeDefined(); - expect(result.current.id).toBe('1'); expect(result.current.width).toBe(100); expect(result.current.height).toBe(100); }); diff --git a/packages/joint-react/src/hooks/__tests__/use-elements.test.ts b/packages/joint-react/src/hooks/__tests__/use-elements.test.ts index cfd1de22c..e299797b8 100644 --- a/packages/joint-react/src/hooks/__tests__/use-elements.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-elements.test.ts @@ -6,19 +6,16 @@ describe('use-elements', () => { const wrapper = graphProviderWrapper({ elements: { '1': { - id: '1', width: 97, height: 99, }, '2': { - id: '2', width: 97, height: 99, }, }, links: { '3': { - id: '3', source: '1', target: '2', }, diff --git a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts index 9d4828988..ec5fd30ae 100644 --- a/packages/joint-react/src/hooks/__tests__/use-graph.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-graph.test.ts @@ -6,7 +6,6 @@ describe('use-graph', () => { const wrapper = graphProviderWrapper({ elements: { '1': { - id: '1', width: 100, height: 100, }, diff --git a/packages/joint-react/src/hooks/__tests__/use-links.test.ts b/packages/joint-react/src/hooks/__tests__/use-links.test.ts index 70670d5b3..29b1aac4b 100644 --- a/packages/joint-react/src/hooks/__tests__/use-links.test.ts +++ b/packages/joint-react/src/hooks/__tests__/use-links.test.ts @@ -16,19 +16,16 @@ describe('use-links', () => { const wrapper = graphProviderWrapper({ elements: { '1': { - id: '1', width: 97, height: 99, }, '2': { - id: '2', width: 97, height: 99, }, }, links: { '3': { - id: '3', source: '1', target: '2', }, @@ -50,7 +47,8 @@ describe('use-links', () => { await waitFor(() => { expect(renders).toHaveBeenCalledTimes(1); expect(Object.keys(result.current).length).toBe(1); - expect(result.current['3'].id).toBe('3'); + expect(result.current['3']).toBeDefined(); + expect(result.current['3'].source).toBe('1'); }); }); diff --git a/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx b/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx index ea188628f..fc2a86cb7 100644 --- a/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx +++ b/packages/joint-react/src/hooks/__tests__/use-paper-context.test.tsx @@ -7,7 +7,6 @@ describe('use-paper-context', () => { graphProviderProps: { elements: { '1': { - id: '1', width: 100, height: 100, }, diff --git a/packages/joint-react/src/hooks/use-cell-actions.stories.tsx b/packages/joint-react/src/hooks/use-cell-actions.stories.tsx index 7814bd5fb..efe907a92 100644 --- a/packages/joint-react/src/hooks/use-cell-actions.stories.tsx +++ b/packages/joint-react/src/hooks/use-cell-actions.stories.tsx @@ -7,6 +7,7 @@ import { BUTTON_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { makeRootDocumentation, makeStory } from '../stories/utils/make-story'; import { getAPILink } from '../stories/utils/get-api-documentation-link'; import { useCellActions } from './use-cell-actions'; +import { useCellId } from './use-cell-id'; const API_URL = getAPILink('useCellActions'); @@ -97,7 +98,8 @@ function Component() { export default meta; -function Hook({ label, id }: Readonly) { +function Hook({ label }: Readonly) { + const id = useCellId(); const { set } = useCellActions(); return ( @@ -116,7 +118,6 @@ export const SetLabel: Story = makeStory({ args: { label: 'default', color: 'red', - id: 'default-id', }, apiURL: API_URL, code: `import { useCellActions } from '@joint/react' @@ -139,7 +140,8 @@ export const SetLabel: Story = makeStory({ description: 'Set new data for the element.', }); -function HookSetPosition({ label, id }: Readonly) { +function HookSetPosition({ label }: Readonly) { + const id = useCellId(); const { set } = useCellActions(); return ( @@ -179,7 +181,8 @@ function HookSetPosition({ label , id }: SimpleElement) { description: 'Set the position of the element.', }); -function HookSetSize({ label, id }: Readonly) { +function HookSetSize({ label }: Readonly) { + const id = useCellId(); const { set } = useCellActions(); return ( @@ -216,7 +219,8 @@ function HookSetSize({ label , id }: SimpleElement) { description: 'Set the size of the element.', }); -function HookSetAngle({ label, id }: Readonly) { +function HookSetAngle({ label }: Readonly) { + const id = useCellId(); const { set } = useCellActions(); return ( @@ -262,7 +266,8 @@ function HookSetAngle({ label , id }: SimpleElement) { description: 'Set the angle of the element.', }); -function HookSetAny({ label, id }: Readonly) { +function HookSetAny({ label }: Readonly) { + const id = useCellId(); const { set } = useCellActions(); return ( @@ -342,7 +347,8 @@ function HookSetAny({ label , id }: SimpleElement) { }); // remove elements -function HookRemoveElement({ label, id }: Readonly) { +function HookRemoveElement({ label }: Readonly) { + const id = useCellId(); const { remove } = useCellActions(); return ( @@ -376,7 +382,8 @@ function HookRemoveElement({ label , id }: SimpleElement) { }); // set link example -function HookSetAndRemoveLink({ label, id }: Readonly) { +function HookSetAndRemoveLink({ label }: Readonly) { + const id = useCellId(); const { remove, set } = useCellActions(); return ( diff --git a/packages/joint-react/src/models/__tests__/react-element.test.ts b/packages/joint-react/src/models/__tests__/react-element.test.ts index 83ace4218..93e64ca77 100644 --- a/packages/joint-react/src/models/__tests__/react-element.test.ts +++ b/packages/joint-react/src/models/__tests__/react-element.test.ts @@ -11,7 +11,6 @@ describe('react-element', () => { describe('ReactElement', () => { it('should create a ReactElement instance', () => { const element = new ReactElement({ - id: '1', position: { x: 10, y: 20 }, size: { width: 100, height: 50 }, }); @@ -23,7 +22,6 @@ describe('react-element', () => { it('should have default attributes', () => { const element = new ReactElement({ - id: '1', position: { x: 10, y: 20 }, size: { width: 100, height: 50 }, }); @@ -34,7 +32,6 @@ describe('react-element', () => { it('should accept custom attributes', () => { const element = new ReactElement({ - id: '1', position: { x: 10, y: 20 }, size: { width: 100, height: 50 }, data: { custom: 'value' }, @@ -49,7 +46,6 @@ describe('react-element', () => { } const element = new ReactElement({ - id: '1', position: { x: 10, y: 20 }, size: { width: 100, height: 50 }, }); @@ -59,7 +55,7 @@ describe('react-element', () => { describe('markup', () => { it('should have empty markup array', () => { - const element = new ReactElement({ id: 'test' }); + const element = new ReactElement(); expect(element.markup).toEqual([]); }); }); @@ -68,7 +64,6 @@ describe('react-element', () => { describe('createElement', () => { it('should create a ReactElement instance', () => { const element = createElement({ - id: '1', position: { x: 10, y: 20 }, size: { width: 100, height: 50 }, }); @@ -86,7 +81,6 @@ describe('react-element', () => { it('should accept custom attributes', () => { const element = createElement({ - id: '1', position: { x: 10, y: 20 }, size: { width: 100, height: 50 }, data: { label: 'Test' }, diff --git a/packages/joint-react/src/models/__tests__/react-link.test.ts b/packages/joint-react/src/models/__tests__/react-link.test.ts index d5bd6dde3..406353395 100644 --- a/packages/joint-react/src/models/__tests__/react-link.test.ts +++ b/packages/joint-react/src/models/__tests__/react-link.test.ts @@ -3,7 +3,7 @@ import { ReactLink, REACT_LINK_TYPE } from '../react-link'; describe('ReactLink', () => { describe('markup', () => { it('should have wrapper and line path markup for link rendering', () => { - const link = new ReactLink({ id: 'test' }); + const link = new ReactLink(); expect(link.markup).toEqual([ { tagName: 'path', @@ -29,14 +29,14 @@ describe('ReactLink', () => { describe('defaults', () => { it('should have REACT_LINK_TYPE as type', () => { - const link = new ReactLink({ id: 'test' }); + const link = new ReactLink(); expect(link.get('type')).toBe(REACT_LINK_TYPE); }); it('should have connection: true attrs for path computation', () => { // This is critical for JointJS to compute the link path (d attribute) // Without connection: true, the path d attribute will be empty/null - const link = new ReactLink({ id: 'test' }); + const link = new ReactLink(); const attributes = link.get('attrs'); expect(attributes?.wrapper?.connection).toBe(true); @@ -44,7 +44,7 @@ describe('ReactLink', () => { }); it('should have default stroke and strokeWidth for line', () => { - const link = new ReactLink({ id: 'test' }); + const link = new ReactLink(); const attributes = link.get('attrs'); expect(attributes?.line?.stroke).toBe('#333333'); @@ -53,7 +53,7 @@ describe('ReactLink', () => { }); it('should have default strokeWidth for wrapper (click target)', () => { - const link = new ReactLink({ id: 'test' }); + const link = new ReactLink(); const attributes = link.get('attrs'); expect(attributes?.wrapper?.strokeWidth).toBe(10); diff --git a/packages/joint-react/src/models/__tests__/react-paper.test.ts b/packages/joint-react/src/models/__tests__/react-paper.test.ts index d1fc7d281..c8da6458e 100644 --- a/packages/joint-react/src/models/__tests__/react-paper.test.ts +++ b/packages/joint-react/src/models/__tests__/react-paper.test.ts @@ -71,52 +71,47 @@ describe('ReactPaper', () => { paper = createPaper(); const element = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, }); graphStore.graph.addCell(element); // After adding cell, view should be in elementCache - expect(elementCache.elementViews['el1']).toBeDefined(); - expect(elementCache.elementViews['el1'].model).toBe(element); + expect(elementCache.elementViews[element.id]).toBeDefined(); + expect(elementCache.elementViews[element.id].model).toBe(element); }); it('should add link view to reactLinkCache when inserted', () => { paper = createPaper(); const element1 = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, }); const element2 = new shapes.standard.Rectangle({ - id: 'el2', position: { x: 200, y: 0 }, size: { width: 100, height: 100 }, }); const link = new shapes.standard.Link({ - id: 'link1', - source: { id: 'el1' }, - target: { id: 'el2' }, + source: { id: element1.id }, + target: { id: element2.id }, }); graphStore.graph.addCells([element1, element2, link]); - expect(linkCache.linkViews['link1']).toBeDefined(); - expect(linkCache.linkViews['link1'].model).toBe(link); + expect(linkCache.linkViews[link.id]).toBeDefined(); + expect(linkCache.linkViews[link.id].model).toBe(link); }); it('should set magnet=false on element views', () => { paper = createPaper(); const element = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, }); graphStore.graph.addCell(element); - const view = elementCache.elementViews['el1']; + const view = elementCache.elementViews[element.id]; expect(view.el.getAttribute('magnet')).toBe('false'); }); @@ -124,23 +119,20 @@ describe('ReactPaper', () => { paper = createPaper(); const element1 = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, }); const element2 = new shapes.standard.Rectangle({ - id: 'el2', position: { x: 200, y: 0 }, size: { width: 100, height: 100 }, }); const link = new shapes.standard.Link({ - id: 'link1', - source: { id: 'el1' }, - target: { id: 'el2' }, + source: { id: element1.id }, + target: { id: element2.id }, }); graphStore.graph.addCells([element1, element2, link]); - const linkView = linkCache.linkViews['link1']; + const linkView = linkCache.linkViews[link.id]; expect(linkView.el.getAttribute('magnet')).not.toBe('false'); }); @@ -150,7 +142,6 @@ describe('ReactPaper', () => { paper = createPaper(); const element = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, }); @@ -165,47 +156,42 @@ describe('ReactPaper', () => { paper = createPaper(); const element = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, }); graphStore.graph.addCell(element); - expect(elementCache.elementViews['el1']).toBeDefined(); + expect(elementCache.elementViews[element.id]).toBeDefined(); graphStore.graph.removeCells([element]); - expect(elementCache.elementViews['el1']).toBeUndefined(); + expect(elementCache.elementViews[element.id]).toBeUndefined(); }); it('should remove view from reactLinkCache when link is removed', () => { paper = createPaper(); const element1 = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, }); const element2 = new shapes.standard.Rectangle({ - id: 'el2', position: { x: 200, y: 0 }, size: { width: 100, height: 100 }, }); const link = new shapes.standard.Link({ - id: 'link1', - source: { id: 'el1' }, - target: { id: 'el2' }, + source: { id: element1.id }, + target: { id: element2.id }, }); graphStore.graph.addCells([element1, element2, link]); - expect(linkCache.linkViews['link1']).toBeDefined(); + expect(linkCache.linkViews[link.id]).toBeDefined(); graphStore.graph.removeCells([link]); - expect(linkCache.linkViews['link1']).toBeUndefined(); + expect(linkCache.linkViews[link.id]).toBeUndefined(); }); it('should call schedulePaperUpdate when view is removed', () => { paper = createPaper(); const element = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, }); @@ -227,18 +213,17 @@ describe('ReactPaper', () => { }); const element = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 50, height: 50 }, }); graphStore.graph.addCell(element); - expect(elementCache.elementViews['el1']).toBeDefined(); + expect(elementCache.elementViews[element.id]).toBeDefined(); // Get the view and call _hideCellView directly const view = paper.findViewByModel(element); paper._hideCellView(view); - expect(elementCache.elementViews['el1']).toBeUndefined(); + expect(elementCache.elementViews[element.id]).toBeUndefined(); }); it('should remove link view from reactLinkCache when hidden', () => { @@ -248,35 +233,31 @@ describe('ReactPaper', () => { }); const element1 = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 50, height: 50 }, }); const element2 = new shapes.standard.Rectangle({ - id: 'el2', position: { x: 200, y: 0 }, size: { width: 50, height: 50 }, }); const link = new shapes.standard.Link({ - id: 'link1', - source: { id: 'el1' }, - target: { id: 'el2' }, + source: { id: element1.id }, + target: { id: element2.id }, }); graphStore.graph.addCells([element1, element2, link]); - expect(linkCache.linkViews['link1']).toBeDefined(); + expect(linkCache.linkViews[link.id]).toBeDefined(); // Get the link view and call _hideCellView directly const linkView = paper.findViewByModel(link); paper._hideCellView(linkView); - expect(linkCache.linkViews['link1']).toBeUndefined(); + expect(linkCache.linkViews[link.id]).toBeUndefined(); }); it('should call schedulePaperUpdate when view is hidden', () => { paper = createPaper(); const element = new shapes.standard.Rectangle({ - id: 'el1', position: { x: 0, y: 0 }, size: { width: 100, height: 100 }, }); diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index 9acd3cbec..a84ba5534 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -69,7 +69,9 @@ interface Options { /** Options to pass to the ResizeObserver constructor */ readonly resizeObserverOptions?: ResizeObserverOptions; /** Function to get the current size of a cell from the graph */ - readonly getCellTransform: (id: dia.Cell.ID) => NodeLayoutOptionalXY & { element: dia.Element; angle: number }; + readonly getCellTransform: ( + id: dia.Cell.ID + ) => NodeLayoutOptionalXY & { element: dia.Element; angle: number }; /** Function to get the current public snapshot containing all elements */ readonly getPublicSnapshot: () => MarkDeepReadOnly; /** Callback function called when a batch of elements needs to be updated */ diff --git a/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx b/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx index 405e5e34e..536305097 100644 --- a/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx +++ b/packages/joint-react/src/stories/examples/with-node-update/code-with-color.tsx @@ -1,19 +1,18 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ /* eslint-disable react-perf/jsx-no-new-function-as-prop */ -import { GraphProvider, Paper, type GraphLink } from '@joint/react'; +import { GraphProvider, Paper, useCellId, type GraphLink } from '@joint/react'; import '../index.css'; import { PRIMARY, LIGHT, PAPER_CLASSNAME } from 'storybook-config/theme'; import { HTMLNode } from 'storybook-config/decorators/with-simple-data'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements: Record = { - '1': { id: '1', label: 'Node 1', color: PRIMARY, x: 100, y: 0, width: 100, height: 50 }, - '2': { id: '2', label: 'Node 2', color: PRIMARY, x: 100, y: 200, width: 100, height: 50 }, +const initialElements: Record = { + '1': { label: 'Node 1', color: PRIMARY, x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'Node 2', color: PRIMARY, x: 100, y: 200, width: 100, height: 50 }, }; const initialEdges: Record = { 'e1-2': { - id: 'e1-2', source: '1', target: '2', attrs: { @@ -26,7 +25,8 @@ const initialEdges: Record = { type BaseElementWithData = (typeof initialElements)[string]; -function RenderElement({ color, id }: Readonly) { +function RenderElement({ color }: Readonly) { + const id = useCellId(); const { set } = useCellActions(); return ( = { - '1': { id: '1', label: 'Node 1', color: '#ffffff', x: 100, y: 0, width: 100, height: 50 }, - '2': { id: '2', label: 'Node 2', color: '#ffffff', x: 100, y: 200, width: 100, height: 50 }, +const initialElements: Record = { + '1': { label: 'Node 1', color: '#ffffff', x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'Node 2', color: '#ffffff', x: 100, y: 200, width: 100, height: 50 }, }; const initialEdges: Record = { 'e1-2': { - id: 'e1-2', source: '1', target: '2', attrs: { @@ -26,7 +25,7 @@ const initialEdges: Record = { type BaseElementWithData = (typeof initialElements)[string]; -function ElementInput({ id, label }: Readonly) { +function ElementInput({ id, label }: Readonly) { const { set } = useCellActions(); return (
- {Object.values(elements).map((item) => { - return ; + {Object.entries(elements).map(([id, item]) => { + return ; })}
diff --git a/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx b/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx index b6e7b6ac5..0f1711d7f 100644 --- a/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx +++ b/packages/joint-react/src/stories/examples/with-rotable-node/code.tsx @@ -1,18 +1,17 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop */ -import { GraphProvider, Paper, useElements, usePaper, useNodeSize } from '@joint/react'; +import { GraphProvider, Paper, useElements, usePaper, useNodeSize, useCellId } from '@joint/react'; import '../index.css'; import { useCallback, useRef } from 'react'; import { PAPER_CLASSNAME, PRIMARY } from 'storybook-config/theme'; import { useCellActions } from '../../../hooks/use-cell-actions'; -const initialElements: Record = { - '1': { id: '1', label: 'Node 1', x: 20, y: 100 }, - '2': { id: '2', label: 'Node 2', x: 200, y: 100 }, +const initialElements: Record = { + '1': { label: 'Node 1', x: 20, y: 100 }, + '2': { label: 'Node 2', x: 200, y: 100 }, }; -const initialEdges: Record = { +const initialEdges: Record = { 'e1-2': { - id: 'e1-2', source: '1', target: '2', attrs: { @@ -25,7 +24,8 @@ const initialEdges: Record) { +function RotatableNode({ label }: Readonly) { + const id = useCellId(); const paper = usePaper(); const { set } = useCellActions(); diff --git a/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx b/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx index 1bfdf9b63..72855b8ed 100644 --- a/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx +++ b/packages/joint-react/src/utils/joint-jsx/jsx-to-markup.stories.tsx @@ -42,13 +42,11 @@ const CustomRect = dia.Element.define( const initialElements: Record = { 'rect1': { type: 'CustomRect', - id: 'rect1', x: 80, y: 80, }, diff --git a/packages/joint-react/src/utils/test-wrappers.tsx b/packages/joint-react/src/utils/test-wrappers.tsx index 00bfd0f38..1b90db7fe 100644 --- a/packages/joint-react/src/utils/test-wrappers.tsx +++ b/packages/joint-react/src/utils/test-wrappers.tsx @@ -60,14 +60,12 @@ export const simpleRenderElementWrapper = paperRenderElementWrapper({ graphProviderProps: { elements: { '1': { - id: '1', width: 97, height: 99, }, }, links: { '3': { - id: '3', source: '1', target: '2', }, diff --git a/packages/joint-react/tsconfig.json b/packages/joint-react/tsconfig.json index 891648614..ca1279aa1 100644 --- a/packages/joint-react/tsconfig.json +++ b/packages/joint-react/tsconfig.json @@ -1,17 +1,17 @@ { "compilerOptions": { - "target": "ES2020", // stable JS output + "target": "ES2020", // stable JS output "lib": ["DOM", "DOM.Iterable", "ESNext"], - "module": "ESNext", // let Vite handle bundling - "moduleResolution": "bundler", // <- **important** - "jsx": "react-jsx", // modern JSX transform - "declaration": true, // emit types for consumers - "declarationMap": true, // useful if publishing + "module": "ESNext", // let Vite handle bundling + "moduleResolution": "bundler", // <- **important** + "jsx": "react-jsx", // modern JSX transform + "declaration": true, // emit types for consumers + "declarationMap": true, // useful if publishing "emitDeclarationOnly": false, "outDir": "lib", "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "isolatedModules": true, // good for Vite + "isolatedModules": true, // good for Vite "strict": true, "noFallthroughCasesInSwitch": true, "forceConsistentCasingInFileNames": true, From 33b456ba0dfc7961955948ebe39ba0e46963d4c8 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Fri, 30 Jan 2026 21:27:11 +0700 Subject: [PATCH 4/5] fix(joint-react): fix links flickering --- .../__tests__/paper-link-flickering.test.tsx | 363 ++++++++++++++++++ .../src/components/paper/paper.types.ts | 2 +- .../joint-react/src/hooks/use-node-size.tsx | 2 +- .../src/models/__tests__/react-paper.test.ts | 282 +++++++++++++- .../joint-react/src/models/react-paper.ts | 125 ++++-- .../create-elements-size-observer.test.ts | 288 ++++++++++++++ .../store/create-elements-size-observer.ts | 218 ++++++++--- packages/joint-react/src/store/paper-store.ts | 60 +-- packages/joint-react/src/store/state-flush.ts | 11 +- 9 files changed, 1232 insertions(+), 119 deletions(-) create mode 100644 packages/joint-react/src/components/paper/__tests__/paper-link-flickering.test.tsx create mode 100644 packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts diff --git a/packages/joint-react/src/components/paper/__tests__/paper-link-flickering.test.tsx b/packages/joint-react/src/components/paper/__tests__/paper-link-flickering.test.tsx new file mode 100644 index 000000000..dfad57f5d --- /dev/null +++ b/packages/joint-react/src/components/paper/__tests__/paper-link-flickering.test.tsx @@ -0,0 +1,363 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +/** + * Test suite to catch the "link flickering" bug. + * + * THE BUG: + * When rendering a Paper with elements and links, the visual sequence is: + * - Frame 1: Empty screen + * - Frame 2: Only links visible (pointing to positions, but no element content) + * - Frame 3: Correct render with both elements and links + * + * This happens because: + * - JointJS renders link SVG paths synchronously + * - React portals render element content via microtask (later) + * + * These tests verify the INVARIANT: + * "If links are visible with rendered paths, elements must also have their content rendered" + */ +import { render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { GraphProvider, Paper, type GraphElement, type GraphLink } from '../../../index'; + +/** + * Flushes the microtask queue by waiting for a microtask to complete. + */ +async function flushMicrotasks(): Promise { + await new Promise((resolve) => { + queueMicrotask(resolve); + }); +} + +interface TestElement extends GraphElement { + readonly label: string; +} + +const TEST_ELEMENTS: Record = { + '1': { label: 'Element1', x: 100, y: 0, width: 100, height: 50 }, + '2': { label: 'Element2', x: 100, y: 200, width: 100, height: 50 }, +}; + +const TEST_LINKS: Record = { + 'link-1': { + source: '1', + target: '2', + }, +}; + +/** + * Helper to check the consistency invariant: + * If links have rendered paths, elements must have their React content. + */ +function checkLinkElementConsistency(container: HTMLElement): { + readonly linksHavePaths: boolean; + readonly elementsHaveContent: boolean; + readonly isConsistent: boolean; +} { + // Check if any link has a rendered path (not empty) + const linkPaths = container.querySelectorAll('.joint-link path[joint-selector="line"]'); + const linksHavePaths = [...linkPaths].some((path) => { + const d = path.getAttribute('d'); + return d && d.length > 0 && d.startsWith('M'); + }); + + // Check if elements have their React content rendered + const elementContents = container.querySelectorAll('.element-content'); + const elementsHaveContent = elementContents.length === Object.keys(TEST_ELEMENTS).length; + + // Consistency: if links are visible, elements must be visible too + const isConsistent = !linksHavePaths || elementsHaveContent; + + return { linksHavePaths, elementsHaveContent, isConsistent }; +} + +/** + * Helper to check that link paths don't point to origin (0,0). + * If links render before elements are positioned, they might have M0,0 paths. + */ +function checkLinkPathsNotAtOrigin(container: HTMLElement): boolean { + const linkPaths = container.querySelectorAll('.joint-link path[joint-selector="line"]'); + + for (const path of linkPaths) { + const d = path.getAttribute('d'); + // A path starting with M0,0 or M 0 0 indicates link rendered at origin + // This happens when elements haven't been positioned yet + if (d && /^M\s*0[,\s]+0/.test(d)) { + return false; + } + } + + return true; +} + +describe('Paper link flickering prevention', () => { + it('INVARIANT: links should not have paths before element content is rendered (SVG mode)', async () => { + const { container } = render( + + + renderElement={({ label }) => ( + +
{label}
+
+ )} + /> +
+ ); + + // Check consistency at multiple timing points + const timingPoints = [ + 'immediate', + 'microtask-1', + 'microtask-2', + 'microtask-3', + 'microtask-4', + 'microtask-5', + ]; + + // Immediate check (before any microtasks) + let result = checkLinkElementConsistency(container); + if (!result.isConsistent) { + throw new Error( + `FLICKERING BUG DETECTED at ${timingPoints[0]}: ` + + `Links have paths (${result.linksHavePaths}) but elements have no content (${result.elementsHaveContent})` + ); + } + + // Check after each microtask flush + for (let index = 1; index <= 5; index++) { + await flushMicrotasks(); + result = checkLinkElementConsistency(container); + if (!result.isConsistent) { + throw new Error( + `FLICKERING BUG DETECTED at ${timingPoints[index]}: ` + + `Links have paths (${result.linksHavePaths}) but elements have no content (${result.elementsHaveContent})` + ); + } + } + + // Final state should have both links and elements + await waitFor(() => { + const finalResult = checkLinkElementConsistency(container); + expect(finalResult.linksHavePaths).toBe(true); + expect(finalResult.elementsHaveContent).toBe(true); + expect(finalResult.isConsistent).toBe(true); + }); + }); + + it('INVARIANT: links should not have paths before element content is rendered (HTML overlay mode)', async () => { + const { container } = render( + + + useHTMLOverlay + renderElement={({ label }) =>
{label}
} + /> +
+ ); + + // Immediate check + let result = checkLinkElementConsistency(container); + if (!result.isConsistent) { + throw new Error( + 'FLICKERING BUG DETECTED (immediate): ' + + `Links have paths (${result.linksHavePaths}) but elements have no content (${result.elementsHaveContent})` + ); + } + + // Check after microtask flushes + for (let index = 0; index < 5; index++) { + await flushMicrotasks(); + result = checkLinkElementConsistency(container); + if (!result.isConsistent) { + throw new Error( + `FLICKERING BUG DETECTED (microtask ${index + 1}): ` + + `Links have paths (${result.linksHavePaths}) but elements have no content (${result.elementsHaveContent})` + ); + } + } + + // Final state check + await waitFor(() => { + const finalResult = checkLinkElementConsistency(container); + expect(finalResult.linksHavePaths).toBe(true); + expect(finalResult.elementsHaveContent).toBe(true); + }); + }); + + it('measureNode returns correct model size for ReactElement', async () => { + // This test verifies that the measureNode callback is set up correctly. + // Note: In jsdom, SVG transform calculations don't work properly, so we can't + // verify the final link path coordinates. But we can verify that: + // 1. Links render (not blocked) + // 2. The link path exists and has valid format + // 3. The measureNode fix is applied (verified by other passing tests) + + const { container } = render( + + + useHTMLOverlay + renderElement={({ label }) =>
{label}
} + /> +
+ ); + + // Wait for link to render + await waitFor( + () => { + const linkPath = container.querySelector('.joint-link path[joint-selector="line"]'); + expect(linkPath).toBeInTheDocument(); + }, + { timeout: 2000 } + ); + + const linkPath = container.querySelector('.joint-link path[joint-selector="line"]'); + const d = linkPath?.getAttribute('d'); + + // Verify path exists and has valid SVG path format (starts with M) + expect(d).toBeTruthy(); + expect(d?.startsWith('M')).toBe(true); + + // Verify it's not an empty path (M and L should have coordinates) + const pathMatch = d?.match(/^M\s*([\d.]+)[,\s]+([\d.]+)\s*L\s*([\d.]+)[,\s]+([\d.]+)/); + expect(pathMatch).toBeTruthy(); + + // Note: We can't verify exact coordinates in jsdom because SVG transforms + // don't work correctly. The real browser will show correct coordinates + // because the measureNode callback returns the model's size, which is then + // transformed by getRootTranslateMatrix() with the element's position. + }); + + it('link paths should have correct coordinates (not at origin 0,0)', async () => { + const { container } = render( + + + useHTMLOverlay + renderElement={({ label }) =>
{label}
} + /> +
+ ); + + // Wait for links to render + await waitFor( + () => { + const linkPath = container.querySelector('.joint-link path[joint-selector="line"]'); + expect(linkPath).toBeInTheDocument(); + expect(linkPath?.getAttribute('d')).toBeTruthy(); + }, + { timeout: 2000 } + ); + + // Verify paths don't point to origin + const pathsValid = checkLinkPathsNotAtOrigin(container); + expect(pathsValid).toBe(true); + + // Additionally, verify the path has reasonable coordinates + const linkPath = container.querySelector('.joint-link path[joint-selector="line"]'); + const d = linkPath?.getAttribute('d'); + + // Parse first coordinate from path (M x,y ...) + const match = d?.match(/^M\s*([\d.]+)[,\s]+([\d.]+)/); + if (match) { + const x = Number.parseFloat(match[1]); + const y = Number.parseFloat(match[2]); + + // The path should start near element 1's position (x: 100, y: 0, width: 100, height: 50) + // So the link source point should be around x: 100-200, y: 0-50 + expect(x).toBeGreaterThan(0); + expect(y).toBeGreaterThanOrEqual(0); + } + }); + + it('should maintain consistency during initial render (no state changes)', async () => { + const { container } = render( + + + useHTMLOverlay + renderElement={({ label }) =>
{label}
} + /> +
+ ); + + // Check consistency at every microtask boundary + for (let index = 0; index < 10; index++) { + const result = checkLinkElementConsistency(container); + // Log any inconsistency for debugging + if (!result.isConsistent) { + // eslint-disable-next-line no-console + console.warn(`Inconsistency at microtask ${index}: links=${result.linksHavePaths}, elements=${result.elementsHaveContent}`); + } + await flushMicrotasks(); + } + + // Final state must have both elements and links + await waitFor(() => { + const elementContents = container.querySelectorAll('.element-content'); + expect(elementContents.length).toBe(2); + + const linkPaths = container.querySelectorAll('.joint-link path[joint-selector="line"]'); + expect(linkPaths.length).toBe(1); + }); + }); + + it('records timing of link vs element rendering for debugging', async () => { + const timingLog: Array<{ + readonly timestamp: number; + readonly phase: string; + readonly linksHavePaths: boolean; + readonly elementsHaveContent: boolean; + }> = []; + + const startTime = performance.now(); + + const { container } = render( + + + useHTMLOverlay + renderElement={({ label }) =>
{label}
} + /> +
+ ); + + // Log immediately + const logState = (phase: string) => { + const result = checkLinkElementConsistency(container); + timingLog.push({ + timestamp: performance.now() - startTime, + phase, + linksHavePaths: result.linksHavePaths, + elementsHaveContent: result.elementsHaveContent, + }); + }; + + logState('immediate'); + + for (let index = 1; index <= 10; index++) { + await flushMicrotasks(); + logState(`microtask-${index}`); + } + + // Wait for final state + await waitFor(() => { + expect(container.querySelector('.element-content')).toBeInTheDocument(); + }); + + logState('final'); + + // Find if there was ever a state where links existed but elements didn't + const flickeringOccurred = timingLog.some( + (entry) => entry.linksHavePaths && !entry.elementsHaveContent + ); + + if (flickeringOccurred) { + // eslint-disable-next-line no-console + console.warn('FLICKERING DETECTED! Links rendered before elements.'); + const flickeringEntries = timingLog.filter( + (entry) => entry.linksHavePaths && !entry.elementsHaveContent + ); + // eslint-disable-next-line no-console + console.warn('Flickering entries:', flickeringEntries); + } + + // This is the actual test assertion + expect(flickeringOccurred).toBe(false); + }); +}); diff --git a/packages/joint-react/src/components/paper/paper.types.ts b/packages/joint-react/src/components/paper/paper.types.ts index 379140753..651aecd08 100644 --- a/packages/joint-react/src/components/paper/paper.types.ts +++ b/packages/joint-react/src/components/paper/paper.types.ts @@ -13,7 +13,7 @@ export interface OnLoadOptions { type ReactPaperOptionsBase = OmitWithoutIndexSignature< dia.Paper.Options, - 'frozen' | 'defaultLink' | 'autoFreeze' | 'viewManagement' + 'frozen' | 'defaultLink' | 'autoFreeze' | 'viewManagement' | 'measureNode' >; export interface ReactPaperOptions extends ReactPaperOptionsBase { /** diff --git a/packages/joint-react/src/hooks/use-node-size.tsx b/packages/joint-react/src/hooks/use-node-size.tsx index 1f92ab890..d5f449380 100644 --- a/packages/joint-react/src/hooks/use-node-size.tsx +++ b/packages/joint-react/src/hooks/use-node-size.tsx @@ -149,7 +149,7 @@ export function useNodeSize( const cell = graph.getCell(id); if (!cell?.isElement()) throw new Error('Cell not valid'); // Check if another useNodeSize hook is already measuring this element - if (hasMeasuredNode(id)) { + if (hasMeasuredNode(id) && process.env.NODE_ENV !== 'production') { const errorMessage = process.env.NODE_ENV === 'production' ? `Multiple useNodeSize hooks detected for element "${id}". Only one useNodeSize hook can be used per element.` diff --git a/packages/joint-react/src/models/__tests__/react-paper.test.ts b/packages/joint-react/src/models/__tests__/react-paper.test.ts index c8da6458e..780d3a58e 100644 --- a/packages/joint-react/src/models/__tests__/react-paper.test.ts +++ b/packages/joint-react/src/models/__tests__/react-paper.test.ts @@ -1,9 +1,10 @@ import { dia, shapes } from '@joint/core'; import { ReactPaper } from '../react-paper'; +import { ReactElement } from '../react-element'; import { GraphStore } from '../../store/graph-store'; import type { ReactElementViewCache, ReactLinkViewCache } from '../../types/paper.types'; -const DEFAULT_CELL_NAMESPACE = shapes; +const DEFAULT_CELL_NAMESPACE = { ...shapes, ReactElement }; describe('ReactPaper', () => { let graphStore: GraphStore; @@ -51,6 +52,13 @@ describe('ReactPaper', () => { return p; } + /** + * Helper to access private pendingLinks for testing + */ + function getPendingLinks(p: ReactPaper): Set { + return (p as unknown as { pendingLinks: Set }).pendingLinks; + } + describe('constructor', () => { it('should create a paper instance', () => { paper = createPaper(); @@ -271,5 +279,277 @@ describe('ReactPaper', () => { expect(scheduleSpy).toHaveBeenCalled(); }); + + it('should remove link from pendingLinks when hidden', () => { + paper = createPaper(); + + // Use ReactElement which has empty markup (like real React usage) + const element1 = new ReactElement({ + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + const element2 = new ReactElement({ + position: { x: 200, y: 0 }, + size: { width: 100, height: 100 }, + }); + const link = new shapes.standard.Link({ + source: { id: element1.id }, + target: { id: element2.id }, + }); + graphStore.graph.addCells([element1, element2, link]); + + const pendingLinks = getPendingLinks(paper); + + // Link should be in pending (source/target have no children - ReactElement has empty markup) + expect(pendingLinks.has(link.id as string)).toBe(true); + + // Hide the link + const linkView = paper.findViewByModel(link); + paper._hideCellView(linkView); + + // Should be removed from pending + expect(pendingLinks.has(link.id as string)).toBe(false); + }); + }); + + describe('pending links visibility', () => { + it('should hide link when source element has no children (ReactElement)', () => { + paper = createPaper(); + + // Use ReactElement which has empty markup (like real React usage) + const element1 = new ReactElement({ + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + const element2 = new ReactElement({ + position: { x: 200, y: 0 }, + size: { width: 100, height: 100 }, + }); + const link = new shapes.standard.Link({ + source: { id: element1.id }, + target: { id: element2.id }, + }); + graphStore.graph.addCells([element1, element2, link]); + + const linkView = linkCache.linkViews[link.id]; + + // Link should be hidden (ReactElement has empty markup, no children) + expect(linkView.el.style.visibility).toBe('hidden'); + }); + + it('should NOT hide link when using standard shapes with default markup', () => { + paper = createPaper(); + + // Standard shapes have default markup with children + const element1 = new shapes.standard.Rectangle({ + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + const element2 = new shapes.standard.Rectangle({ + position: { x: 200, y: 0 }, + size: { width: 100, height: 100 }, + }); + const link = new shapes.standard.Link({ + source: { id: element1.id }, + target: { id: element2.id }, + }); + graphStore.graph.addCells([element1, element2, link]); + + const linkView = linkCache.linkViews[link.id]; + const pendingLinks = getPendingLinks(paper); + + // Standard shapes have children, so link should NOT be hidden or pending + expect(linkView.el.style.visibility).toBe(''); + expect(pendingLinks.has(link.id as string)).toBe(false); + }); + + it('should add link to pendingLinks when source/target not ready (ReactElement)', () => { + paper = createPaper(); + + // Use ReactElement which has empty markup + const element1 = new ReactElement({ + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + const element2 = new ReactElement({ + position: { x: 200, y: 0 }, + size: { width: 100, height: 100 }, + }); + const link = new shapes.standard.Link({ + source: { id: element1.id }, + target: { id: element2.id }, + }); + graphStore.graph.addCells([element1, element2, link]); + + const pendingLinks = getPendingLinks(paper); + + expect(pendingLinks.has(link.id as string)).toBe(true); + }); + + it('should show link when source and target elements have children', () => { + paper = createPaper(); + + // Use ReactElement which has empty markup + const element1 = new ReactElement({ + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + const element2 = new ReactElement({ + position: { x: 200, y: 0 }, + size: { width: 100, height: 100 }, + }); + const link = new shapes.standard.Link({ + source: { id: element1.id }, + target: { id: element2.id }, + }); + graphStore.graph.addCells([element1, element2, link]); + + const linkView = linkCache.linkViews[link.id]; + const element1View = elementCache.elementViews[element1.id]; + const element2View = elementCache.elementViews[element2.id]; + + // Initially hidden + expect(linkView.el.style.visibility).toBe('hidden'); + + // Simulate React rendering children by adding child elements + const child1 = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + const child2 = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + element1View.el.append(child1); + element2View.el.append(child2); + + // Call checkPendingLinks to process + paper.checkPendingLinks(); + + // Link should now be visible + expect(linkView.el.style.visibility).toBe(''); + }); + + it('should remove link from pendingLinks after showing', () => { + paper = createPaper(); + + // Use ReactElement which has empty markup + const element1 = new ReactElement({ + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + const element2 = new ReactElement({ + position: { x: 200, y: 0 }, + size: { width: 100, height: 100 }, + }); + const link = new shapes.standard.Link({ + source: { id: element1.id }, + target: { id: element2.id }, + }); + graphStore.graph.addCells([element1, element2, link]); + + const pendingLinks = getPendingLinks(paper); + const element1View = elementCache.elementViews[element1.id]; + const element2View = elementCache.elementViews[element2.id]; + + // Initially in pending + expect(pendingLinks.has(link.id as string)).toBe(true); + + // Simulate React rendering children + element1View.el.append(document.createElementNS('http://www.w3.org/2000/svg', 'rect')); + element2View.el.append(document.createElementNS('http://www.w3.org/2000/svg', 'rect')); + + paper.checkPendingLinks(); + + // Should be removed from pending + expect(pendingLinks.has(link.id as string)).toBe(false); + }); + + it('should not show link if only source is ready', () => { + paper = createPaper(); + + // Use ReactElement which has empty markup + const element1 = new ReactElement({ + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + const element2 = new ReactElement({ + position: { x: 200, y: 0 }, + size: { width: 100, height: 100 }, + }); + const link = new shapes.standard.Link({ + source: { id: element1.id }, + target: { id: element2.id }, + }); + graphStore.graph.addCells([element1, element2, link]); + + const linkView = linkCache.linkViews[link.id]; + const element1View = elementCache.elementViews[element1.id]; + + // Only add children to source element + element1View.el.append(document.createElementNS('http://www.w3.org/2000/svg', 'rect')); + + paper.checkPendingLinks(); + + // Link should still be hidden (target has no children) + expect(linkView.el.style.visibility).toBe('hidden'); + }); + + it('should clean up pendingLinks when link is removed', () => { + paper = createPaper(); + + // Use ReactElement which has empty markup + const element1 = new ReactElement({ + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + const element2 = new ReactElement({ + position: { x: 200, y: 0 }, + size: { width: 100, height: 100 }, + }); + const link = new shapes.standard.Link({ + source: { id: element1.id }, + target: { id: element2.id }, + }); + graphStore.graph.addCells([element1, element2, link]); + + const pendingLinks = getPendingLinks(paper); + + // Link should be in pending + expect(pendingLinks.has(link.id as string)).toBe(true); + + // Remove the link + graphStore.graph.removeCells([link]); + + // Should be cleaned up + expect(pendingLinks.has(link.id as string)).toBe(false); + }); + + it('should handle checkPendingLinks when link view was removed', () => { + paper = createPaper(); + + // Use ReactElement which has empty markup + const element1 = new ReactElement({ + position: { x: 0, y: 0 }, + size: { width: 100, height: 100 }, + }); + const element2 = new ReactElement({ + position: { x: 200, y: 0 }, + size: { width: 100, height: 100 }, + }); + const link = new shapes.standard.Link({ + source: { id: element1.id }, + target: { id: element2.id }, + }); + graphStore.graph.addCells([element1, element2, link]); + + const pendingLinks = getPendingLinks(paper); + + // Link should be in pending + expect(pendingLinks.has(link.id as string)).toBe(true); + + // Manually remove from linkCache but keep in pendingLinks (simulating race condition) + Reflect.deleteProperty(linkCache.linkViews, link.id); + + // Should not throw + expect(() => paper.checkPendingLinks()).not.toThrow(); + + // Should clean up the orphaned pending link + expect(pendingLinks.has(link.id as string)).toBe(false); + }); }); }); diff --git a/packages/joint-react/src/models/react-paper.ts b/packages/joint-react/src/models/react-paper.ts index 59c0bdc54..be7e6f13c 100644 --- a/packages/joint-react/src/models/react-paper.ts +++ b/packages/joint-react/src/models/react-paper.ts @@ -10,6 +10,7 @@ import type { ReactElementViewCache, ReactLinkViewCache } from '../types/paper.t * - Tracking mounted views in React caches for portal rendering * - Scheduling React updates when views mount/unmount * - Disabling magnets on React elements + * - Hiding links until their source/target elements have rendered * @example * ```typescript * const paper = new ReactPaper({ @@ -29,14 +30,86 @@ export class ReactPaper extends dia.Paper { /** Cache for link views - set by PaperStore */ public reactLinkCache!: ReactLinkViewCache; + /** Links waiting for source/target elements to render */ + private pendingLinks: Set = new Set(); + constructor(options: ReactPaperOptions) { super(options); this.graphStore = options.graphStore; } + /** + * Check if an element view has rendered its React content. + * @param elementId - The element ID to check + * @returns true if element view exists and has children + */ + private isElementReady(elementId: dia.Cell.ID | undefined): boolean { + if (!elementId) return false; + const elementView = this.reactElementCache.elementViews[elementId]; + return !!elementView?.el && elementView.el.children.length > 0; + } + + /** + * Check pending links and show them if their source/target are ready. + * Called after React renders element content. + */ + public checkPendingLinks(): void { + if (this.pendingLinks.size === 0) return; + + const linksToShow: dia.Cell.ID[] = []; + + for (const linkId of this.pendingLinks) { + const linkView = this.reactLinkCache.linkViews[linkId]; + if (!linkView) { + // Link was removed, clean up + this.pendingLinks.delete(linkId); + continue; + } + + const link = linkView.model; + const sourceId = link.source().id as dia.Cell.ID; + const targetId = link.target().id as dia.Cell.ID; + + if (this.isElementReady(sourceId) && this.isElementReady(targetId)) { + linksToShow.push(linkId); + } + } + + // Show ready links + for (const linkId of linksToShow) { + this.pendingLinks.delete(linkId); + const linkView = this.reactLinkCache.linkViews[linkId]; + if (linkView?.el) { + linkView.el.style.visibility = ''; + } + } + } + + /** + * Remove a cell from the appropriate cache. + * Uses Reflect.deleteProperty to satisfy `@typescript-eslint/no-dynamic-delete` rule. + * Performance is identical to `delete` operator. + * @param cell - The cell to remove from cache + */ + private removeFromCache(cell: dia.Cell): void { + const cellId = cell.id; + + if (cell.isElement()) { + const newElementViews = { ...this.reactElementCache.elementViews }; + Reflect.deleteProperty(newElementViews, cellId); + this.reactElementCache.elementViews = newElementViews; + } else if (cell.isLink()) { + const newLinkViews = { ...this.reactLinkCache.linkViews }; + Reflect.deleteProperty(newLinkViews, cellId); + this.reactLinkCache.linkViews = newLinkViews; + this.pendingLinks.delete(cellId); + } + } + /** * Called when a view is mounted into the DOM. * Adds view to appropriate cache and schedules React update. + * For links, hides them until source/target elements have rendered. * @param view - The cell view being inserted * @param isInitialInsert - Whether this is the initial insert */ @@ -55,8 +128,24 @@ export class ReactPaper extends dia.Paper { ...this.reactElementCache.elementViews, [cellId]: view as dia.ElementView, }; + + // Check if any pending links can now be shown + this.checkPendingLinks(); } else if (view.model.isLink()) { const linkView = view as dia.LinkView; + const link = linkView.model; + const sourceId = link.source().id as dia.Cell.ID; + const targetId = link.target().id as dia.Cell.ID; + + // Check if source/target elements have rendered their React content + const isSourceReady = this.isElementReady(sourceId); + const isTargetReady = this.isElementReady(targetId); + + if (!isSourceReady || !isTargetReady) { + // Hide link until source/target are ready + view.el.style.visibility = 'hidden'; + this.pendingLinks.add(cellId); + } // Add to link views cache this.reactLinkCache.linkViews = { @@ -76,24 +165,8 @@ export class ReactPaper extends dia.Paper { * @returns The removed cell view */ removeView(cell: dia.Cell): dia.CellView { - const cellId = cell.id; - - if (cell.isElement()) { - // Remove from element views cache - const newElementViews = { ...this.reactElementCache.elementViews }; - Reflect.deleteProperty(newElementViews, cellId); - this.reactElementCache.elementViews = newElementViews; - } else if (cell.isLink()) { - // Remove from link views cache - const newLinkViews = { ...this.reactLinkCache.linkViews }; - Reflect.deleteProperty(newLinkViews, cellId); - this.reactLinkCache.linkViews = newLinkViews; - } - - // Schedule React update + this.removeFromCache(cell); this.graphStore.schedulePaperUpdate(); - - // Call parent implementation return super.removeView(cell); } @@ -104,24 +177,8 @@ export class ReactPaper extends dia.Paper { * @internal */ _hideCellView(cellView: dia.CellView): void { - const cellId = cellView.model.id; - - if (cellView.model.isElement()) { - // Remove from element views cache - const newElementViews = { ...this.reactElementCache.elementViews }; - Reflect.deleteProperty(newElementViews, cellId); - this.reactElementCache.elementViews = newElementViews; - } else if (cellView.model.isLink()) { - // Remove from link views cache - const newLinkViews = { ...this.reactLinkCache.linkViews }; - Reflect.deleteProperty(newLinkViews, cellId); - this.reactLinkCache.linkViews = newLinkViews; - } - - // Schedule React update + this.removeFromCache(cellView.model); this.graphStore.schedulePaperUpdate(); - - // Call parent implementation super._hideCellView(cellView); } } diff --git a/packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts b/packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts new file mode 100644 index 000000000..efb1eed89 --- /dev/null +++ b/packages/joint-react/src/store/__tests__/create-elements-size-observer.test.ts @@ -0,0 +1,288 @@ +/* eslint-disable prefer-destructuring */ +/* eslint-disable sonarjs/no-nested-functions */ +/* eslint-disable @typescript-eslint/no-require-imports */ +import type { dia } from '@joint/core'; +import type { GraphStoreSnapshot } from '../graph-store'; +import type { GraphElement } from '../../types/element-types'; +import type { GraphStoreObserver } from '../create-elements-size-observer'; + +// Mock ResizeObserver for testing +// eslint-disable-next-line sonarjs/public-static-readonly +let mockResizeObserverInstances: MockResizeObserver[] = []; + +class MockResizeObserver { + private callback: ResizeObserverCallback; + private observedElements = new Map(); + + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + mockResizeObserverInstances.push(this); + } + + observe(target: Element) { + // Simulate an entry with initial size + const entry = this.createEntry(target, 100, 50); + this.observedElements.set(target, entry); + } + + unobserve(target: Element) { + this.observedElements.delete(target); + } + + disconnect() { + this.observedElements.clear(); + } + + // Test helper to simulate resize + triggerResize(target: Element, width: number, height: number) { + const entry = this.createEntry(target, width, height); + this.observedElements.set(target, entry); + this.callback([entry], this as unknown as ResizeObserver); + } + + // Test helper to trigger callback for all observed elements + triggerAllCallbacks() { + const entries = [...this.observedElements.values()]; + if (entries.length > 0) { + this.callback(entries, this as unknown as ResizeObserver); + } + } + + private createEntry(target: Element, width: number, height: number): ResizeObserverEntry { + return { + target, + contentRect: { + width, + height, + top: 0, + left: 0, + bottom: height, + right: width, + x: 0, + y: 0, + toJSON: () => ({}), + }, + borderBoxSize: [{ inlineSize: width, blockSize: height }], + contentBoxSize: [{ inlineSize: width, blockSize: height }], + devicePixelContentBoxSize: [{ inlineSize: width, blockSize: height }], + } as ResizeObserverEntry; + } + + static getLastInstance(): MockResizeObserver | undefined { + return mockResizeObserverInstances.at(-1); + } + + static clearInstances() { + mockResizeObserverInstances = []; + } +} + +// Replace global ResizeObserver with mock +globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + +describe('createElementsSizeObserver', () => { + let observer: GraphStoreObserver; + let mockOnBatchUpdate: jest.Mock; + let mockGetCellTransform: jest.Mock; + let mockGetPublicSnapshot: jest.Mock; + let mockElements: Record; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let createElementsSizeObserver: any; + + beforeEach(() => { + MockResizeObserver.clearInstances(); + + // Reset modules and reimport to ensure the mock is used + jest.resetModules(); + // Re-assign the mock after reset to ensure it's used + globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + // eslint-disable-next-line unicorn/prefer-module + createElementsSizeObserver = require('../create-elements-size-observer').createElementsSizeObserver; + + mockElements = { + 'element-1': { x: 0, y: 0, width: 1, height: 1, type: 'ReactElement' }, + 'element-2': { x: 100, y: 100, width: 1, height: 1, type: 'ReactElement' }, + }; + + mockOnBatchUpdate = jest.fn(); + mockGetCellTransform = jest.fn((id: dia.Cell.ID) => ({ + width: 1, + height: 1, + x: 0, + y: 0, + angle: 0, + element: { id } as dia.Element, + })); + mockGetPublicSnapshot = jest.fn( + () => + ({ + elements: mockElements, + links: {}, + }) as GraphStoreSnapshot + ); + + observer = createElementsSizeObserver({ + onBatchUpdate: mockOnBatchUpdate, + getCellTransform: mockGetCellTransform, + getPublicSnapshot: mockGetPublicSnapshot, + }); + }); + + afterEach(() => { + observer.clean(); + }); + + describe('add', () => { + it('should register element with ResizeObserver', () => { + const element = document.createElement('div'); + + observer.add({ id: 'element-1', element }); + + expect(observer.has('element-1')).toBe(true); + }); + + it('should return cleanup function that unregisters element', () => { + const element = document.createElement('div'); + + const cleanup = observer.add({ id: 'element-1', element }); + expect(observer.has('element-1')).toBe(true); + + cleanup(); + expect(observer.has('element-1')).toBe(false); + }); + + it('should handle multiple elements', () => { + const element1 = document.createElement('div'); + const element2 = document.createElement('div'); + + observer.add({ id: 'element-1', element: element1 }); + observer.add({ id: 'element-2', element: element2 }); + + expect(observer.has('element-1')).toBe(true); + expect(observer.has('element-2')).toBe(true); + }); + + it('should process ResizeObserver callback when element is added', () => { + const element = document.createElement('div'); + + observer.add({ id: 'element-1', element }); + + // Trigger resize via ResizeObserver (simulates browser behavior) + const resizeObserver = MockResizeObserver.getLastInstance(); + expect(resizeObserver).toBeDefined(); + resizeObserver?.triggerResize(element, 100, 50); + + expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); + + const updateCall = mockOnBatchUpdate.mock.calls[0][0]; + expect(updateCall['element-1']).toBeDefined(); + expect(updateCall['element-1'].width).toBe(100); + expect(updateCall['element-1'].height).toBe(50); + }); + + it('should handle multiple elements with ResizeObserver', () => { + const element1 = document.createElement('div'); + const element2 = document.createElement('div'); + + observer.add({ id: 'element-1', element: element1 }); + observer.add({ id: 'element-2', element: element2 }); + + // Trigger resize for both elements + const resizeObserver = MockResizeObserver.getLastInstance(); + resizeObserver?.triggerResize(element1, 100, 50); + resizeObserver?.triggerResize(element2, 200, 100); + + expect(mockOnBatchUpdate).toHaveBeenCalledTimes(2); + }); + }); + + describe('ResizeObserver callback', () => { + it('should process size changes from ResizeObserver', () => { + const element = document.createElement('div'); + + observer.add({ id: 'element-1', element }); + + const resizeObserver = MockResizeObserver.getLastInstance(); + expect(resizeObserver).toBeDefined(); + + // Trigger initial resize + resizeObserver?.triggerResize(element, 100, 50); + expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); + + // Trigger resize to a different size + resizeObserver?.triggerResize(element, 200, 100); + + // Should be called again for the resize + expect(mockOnBatchUpdate).toHaveBeenCalledTimes(2); + }); + + it('should not update if size has not changed significantly', () => { + const element = document.createElement('div'); + + observer.add({ id: 'element-1', element }); + + const resizeObserver = MockResizeObserver.getLastInstance(); + + // Trigger initial resize + resizeObserver?.triggerResize(element, 100, 50); + expect(mockOnBatchUpdate).toHaveBeenCalledTimes(1); + mockOnBatchUpdate.mockClear(); + + // Trigger resize with same size (within epsilon of 0.5) + resizeObserver?.triggerResize(element, 100.1, 50.1); + + // Should not trigger update because change is within epsilon + expect(mockOnBatchUpdate).not.toHaveBeenCalled(); + }); + + it('should use transform function when provided', () => { + const element = document.createElement('div'); + const transform = jest.fn(({ width, height }) => ({ + width: width + 20, + height: height + 20, + })); + + observer.add({ id: 'element-1', element, transform }); + + const resizeObserver = MockResizeObserver.getLastInstance(); + resizeObserver?.triggerResize(element, 100, 50); + + expect(transform).toHaveBeenCalled(); + + const updateCall = mockOnBatchUpdate.mock.calls[0][0]; + expect(updateCall['element-1'].width).toBe(120); // 100 + 20 + expect(updateCall['element-1'].height).toBe(70); // 50 + 20 + }); + }); + + describe('clean', () => { + it('should remove all observed elements', () => { + const element1 = document.createElement('div'); + const element2 = document.createElement('div'); + + observer.add({ id: 'element-1', element: element1 }); + observer.add({ id: 'element-2', element: element2 }); + + expect(observer.has('element-1')).toBe(true); + expect(observer.has('element-2')).toBe(true); + + observer.clean(); + + expect(observer.has('element-1')).toBe(false); + expect(observer.has('element-2')).toBe(false); + }); + }); + + describe('has', () => { + it('should return true for registered elements', () => { + const element = document.createElement('div'); + observer.add({ id: 'element-1', element }); + + expect(observer.has('element-1')).toBe(true); + }); + + it('should return false for unregistered elements', () => { + expect(observer.has('non-existent')).toBe(false); + }); + }); +}); diff --git a/packages/joint-react/src/store/create-elements-size-observer.ts b/packages/joint-react/src/store/create-elements-size-observer.ts index a84ba5534..7064ea44f 100644 --- a/packages/joint-react/src/store/create-elements-size-observer.ts +++ b/packages/joint-react/src/store/create-elements-size-observer.ts @@ -111,6 +111,89 @@ function roundToTwoDecimals(value: number) { return Math.round(value * 100) / 100; } +/** + * Options for processing a single element's size change. + */ +interface ProcessSizeChangeOptions { + readonly cellId: dia.Cell.ID; + readonly measuredWidth: number; + readonly measuredHeight: number; + readonly observedElement: ObservedElement | Partial; + readonly getCellTransform: Options['getCellTransform']; + readonly updatedElements: Record; +} + +/** + * Processes a size change for a single element. + * Returns true if the element was updated, false otherwise. + */ +function processSizeChange(options: ProcessSizeChangeOptions): boolean { + const { + cellId, + measuredWidth, + measuredHeight, + observedElement, + getCellTransform, + updatedElements, + } = options; + + const currentCellTransform = getCellTransform(cellId); + + // Compare the measured size with the current cell size using epsilon to avoid jitter + const hasSizeChanged = + Math.abs(currentCellTransform.width - measuredWidth) > EPSILON || + Math.abs(currentCellTransform.height - measuredHeight) > EPSILON; + + if (!hasSizeChanged) { + return false; + } + + // We observe just width and height, not x and y + if ( + currentCellTransform.width === measuredWidth && + currentCellTransform.height === measuredHeight + ) { + return false; + } + + const graphElement = updatedElements[cellId]; + if (!graphElement) { + return false; + } + + const { transform: sizeTransformFunction = defaultTransform } = observedElement; + + const lastWidth = roundToTwoDecimals(observedElement.lastWidth ?? 0); + const lastHeight = roundToTwoDecimals(observedElement.lastHeight ?? 0); + + // Check if the change is significant compared to the last observed size + const widthDifference = Math.abs(lastWidth - measuredWidth); + const heightDifference = Math.abs(lastHeight - measuredHeight); + if (widthDifference <= EPSILON && heightDifference <= EPSILON) { + return false; + } + + // Update cached size values + observedElement.lastWidth = measuredWidth; + observedElement.lastHeight = measuredHeight; + + const { x, y, angle, element: cell } = currentCellTransform; + updatedElements[cellId] = { + ...graphElement, + ...sizeTransformFunction({ + x: x ?? 0, + y: y ?? 0, + angle: angle ?? 0, + element: cell, + width: measuredWidth, + height: measuredHeight, + id: cellId, + }), + }; + + return true; +} + /** * Creates an observer for element size changes using the ResizeObserver API. * @@ -123,6 +206,7 @@ function roundToTwoDecimals(value: number) { * - Batches multiple size changes together for performance * - Compares sizes with epsilon to avoid jitter from sub-pixel rendering * - Supports custom size update handlers + * - Performs immediate synchronous measurement on add to prevent flickering * @param options - The options for creating the size observer * @returns A GraphStoreObserver instance with methods to add/remove observers */ @@ -135,6 +219,66 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver } = options; const observedElementsByCellId = new Map(); const cellIdByDomElement = new Map(); + + // Pending immediate measurements to batch + const pendingImmediateMeasurements = new Map(); + let isImmediateBatchScheduled = false; + + /** + * Flushes all pending immediate measurements in a single batch update. + */ + function flushImmediateMeasurements() { + if (pendingImmediateMeasurements.size === 0) { + isImmediateBatchScheduled = false; + return; + } + + const publicSnapshot = getPublicSnapshot(); + const elementsRecord = publicSnapshot.elements as Record; + const updatedElements: Record = { ...elementsRecord }; + let hasAnySizeChange = false; + + for (const [cellId, { width, height }] of pendingImmediateMeasurements) { + const observedElement = observedElementsByCellId.get(cellId) ?? DEFAULT_OBSERVED_ELEMENT; + + const measuredWidth = roundToTwoDecimals(width); + const measuredHeight = roundToTwoDecimals(height); + + const wasUpdated = processSizeChange({ + cellId, + measuredWidth, + measuredHeight, + observedElement, + getCellTransform, + updatedElements, + }); + + if (wasUpdated) { + hasAnySizeChange = true; + } + } + + pendingImmediateMeasurements.clear(); + isImmediateBatchScheduled = false; + + if (hasAnySizeChange) { + onBatchUpdate(updatedElements); + } + } + + /** + * Schedules an immediate measurement to be processed in the current microtask batch. + */ + function scheduleImmediateMeasurement(cellId: dia.Cell.ID, width: number, height: number) { + pendingImmediateMeasurements.set(cellId, { width, height }); + + if (!isImmediateBatchScheduled) { + isImmediateBatchScheduled = true; + // Use queueMicrotask for synchronous-like batching within the same execution context + queueMicrotask(flushImmediateMeasurements); + } + } + const observer = new ResizeObserver((entries) => { // Process all entries as a single batch let hasAnySizeChange = false; @@ -162,62 +306,20 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver const measuredWidth = roundToTwoDecimals(inlineSize); const measuredHeight = roundToTwoDecimals(blockSize); - const currentCellTransform = getCellTransform(cellId); - - // Compare the measured size with the current cell size using epsilon to avoid jitter - const hasSizeChanged = - Math.abs(currentCellTransform.width - measuredWidth) > EPSILON || - Math.abs(currentCellTransform.height - measuredHeight) > EPSILON; - - if (!hasSizeChanged) { - continue; - } - - // We observe just width and height, not x and y - if ( - currentCellTransform.width === measuredWidth && - currentCellTransform.height === measuredHeight - ) { - continue; - } - - const graphElement = updatedElements[cellId]; - if (!graphElement) { - throw new Error(`Element with id ${cellId} not found in graph data ref`); - } - const observedElement = observedElementsByCellId.get(cellId) ?? DEFAULT_OBSERVED_ELEMENT; - const { transform: sizeTransformFunction = defaultTransform } = observedElement; - const lastWidth = roundToTwoDecimals(observedElement.lastWidth ?? 0); - const lastHeight = roundToTwoDecimals(observedElement.lastHeight ?? 0); + const wasUpdated = processSizeChange({ + cellId, + measuredWidth, + measuredHeight, + observedElement, + getCellTransform, + updatedElements, + }); - // Check if the change is significant compared to the last observed size - const widthDifference = Math.abs(lastWidth - measuredWidth); - const heightDifference = Math.abs(lastHeight - measuredHeight); - if (widthDifference <= EPSILON && heightDifference <= EPSILON) { - continue; + if (wasUpdated) { + hasAnySizeChange = true; } - - // Update cached size values - observedElement.lastWidth = measuredWidth; - observedElement.lastHeight = measuredHeight; - - const { x, y, angle, element: cell } = currentCellTransform; - updatedElements[cellId] = { - ...graphElement, - ...sizeTransformFunction({ - x: x ?? 0, - y: y ?? 0, - angle: angle ?? 0, - element: cell, - width: measuredWidth, - height: measuredHeight, - id: cellId, - }), - }; - - hasAnySizeChange = true; } if (!hasAnySizeChange) { @@ -230,13 +332,23 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver return { add({ id, element, transform }: SetMeasuredNodeOptions) { + // Register with ResizeObserver for future changes observer.observe(element, resizeObserverOptions); observedElementsByCellId.set(id, { element, transform }); cellIdByDomElement.set(element, id); + + // Perform immediate synchronous measurement to prevent flickering + // This eliminates the 2ms delay from ResizeObserver callback + const rect = element.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + scheduleImmediateMeasurement(id, rect.width, rect.height); + } + return () => { observer.unobserve(element); observedElementsByCellId.delete(id); cellIdByDomElement.delete(element); + pendingImmediateMeasurements.delete(id); }; }, clean() { @@ -245,6 +357,8 @@ export function createElementsSizeObserver(options: Options): GraphStoreObserver } observedElementsByCellId.clear(); cellIdByDomElement.clear(); + pendingImmediateMeasurements.clear(); + isImmediateBatchScheduled = false; observer.disconnect(); }, has(id: dia.Cell.ID) { diff --git a/packages/joint-react/src/store/paper-store.ts b/packages/joint-react/src/store/paper-store.ts index 5a33bbfa4..0c421013e 100644 --- a/packages/joint-react/src/store/paper-store.ts +++ b/packages/joint-react/src/store/paper-store.ts @@ -146,6 +146,9 @@ export class PaperStore { // Create a new ReactPaper instance // ReactPaper handles view lifecycle internally via insertView/removeView + // NOTE: We don't use cellVisibility to hide links because JointJS's + // unmountedList.rotate() causes O(n) checks per frame when returning false. + // Link visibility should be handled in React layer instead. const paper = new ReactPaper({ async: true, sorting: dia.Paper.sorting.APPROX, @@ -160,41 +163,44 @@ export class PaperStore { afterRender: (() => { // Re-entrancy guard to prevent infinite loops let isProcessing = false; - return () => { + return function (this: ReactPaper) { if (isProcessing) { return; } isProcessing = true; - try { - // Iterate through all element views and capture port elements - let hasPortsChanged = false; - for (const view of Object.values(cache.elementViews)) { - const portElementsCache = ( - view as dia.ElementView & { - _portElementsCache?: Record; - } - )._portElementsCache; - if (!portElementsCache) { - continue; - } - const newPorts = store.getNewPorts({ - state: graphStore.internalState, - cellId: view.model.id as dia.Cell.ID, - portElementsCache, - portsData: cache.portsData, - }); - if (newPorts && newPorts !== cache.portsData) { - cache.portsData = newPorts; - hasPortsChanged = true; + + // Check if any pending links can now be shown + this.checkPendingLinks(); + + // Iterate through all element views and capture port elements + let hasPortsChanged = false; + for (const view of Object.values(cache.elementViews)) { + const portElementsCache = ( + view as dia.ElementView & { + _portElementsCache?: Record; } + )._portElementsCache; + if (!portElementsCache) { + continue; } - // Only schedule update if ports actually changed - if (hasPortsChanged) { - graphStore.schedulePaperUpdate(); + const newPorts = store.getNewPorts({ + state: graphStore.internalState, + cellId: view.model.id as dia.Cell.ID, + portElementsCache, + portsData: cache.portsData, + }); + if (newPorts && newPorts !== cache.portsData) { + cache.portsData = newPorts; + hasPortsChanged = true; } - } finally { - isProcessing = false; } + + // Only schedule update if ports actually changed + if (hasPortsChanged) { + graphStore.schedulePaperUpdate(); + } + + isProcessing = false; }; })(), ...paperOptions, diff --git a/packages/joint-react/src/store/state-flush.ts b/packages/joint-react/src/store/state-flush.ts index a69e89f63..ec510d069 100644 --- a/packages/joint-react/src/store/state-flush.ts +++ b/packages/joint-react/src/store/state-flush.ts @@ -11,6 +11,11 @@ import type { } from './graph-store'; import type { PaperStore } from './paper-store'; +/** + * Default point used as fallback when position is not available. + */ +const DEFAULT_POINT = { x: 0, y: 0 } as const; + /** * GOLDEN RULE: All setState calls must happen through these flush functions. * This module isolates state mutations to ensure they only happen in scheduler's onFlush. @@ -116,7 +121,7 @@ export function flushLayoutState(options: FlushLayoutStateOptions): void { for (const element of elements) { const size = element.get('size'); - const position = element.get('position') ?? { x: 0, y: 0 }; + const position = element.get('position') ?? DEFAULT_POINT; const angle = element.get('angle') ?? 0; if (!size) continue; @@ -153,8 +158,8 @@ export function flushLayoutState(options: FlushLayoutStateOptions): void { const linkView = paper.findViewByModel(link) as dia.LinkView | null; if (!linkView) continue; - const sourcePoint = linkView.sourcePoint ?? { x: 0, y: 0 }; - const targetPoint = linkView.targetPoint ?? { x: 0, y: 0 }; + const sourcePoint = linkView.sourcePoint ?? DEFAULT_POINT; + const targetPoint = linkView.targetPoint ?? DEFAULT_POINT; const d = linkView.getSerializedConnection?.() ?? ''; const newLinkLayout: LinkLayout = { From 26e2ff974cbb5d8368f20463a40b06acbc1f96d3 Mon Sep 17 00:00:00 2001 From: samuelgja Date: Wed, 11 Feb 2026 18:58:54 +0700 Subject: [PATCH 5/5] chore(joint-react): add claude.md --- packages/joint-react/CLAUDE.md | 103 +++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/joint-react/CLAUDE.md diff --git a/packages/joint-react/CLAUDE.md b/packages/joint-react/CLAUDE.md new file mode 100644 index 000000000..a3666e430 --- /dev/null +++ b/packages/joint-react/CLAUDE.md @@ -0,0 +1,103 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`@joint/react` is the React bindings library for JointJS. It provides React components and hooks for building diagramming applications with JointJS, rendering diagram elements as React components via SVG foreignObject portals. + +**Stack:** TypeScript 5.9, React 19 (peer: >=18 <20), esbuild (build), Vite (storybook/dev), Jest 30 + @testing-library/react (testing), ESLint 9 flat config, Storybook 10 + +## Common Commands + +```bash +# Build (esbuild β†’ dist/cjs, dist/esm, dist/types) +yarn build + +# Run all checks (typecheck + lint + jest) +yarn test + +# Run individual checks +yarn typecheck # tsc --noEmit +yarn lint # ESLint +yarn lint-fix # ESLint with auto-fix +yarn jest # Jest tests only + +# Run a single test file +yarn jest --testPathPattern="use-elements" + +# Run tests in watch mode +yarn jest --watch + +# Storybook (dev server on :6006) +yarn storybook + +# Build storybook +yarn build-storybook +``` + +## Architecture + +### Three Control Modes + +The library supports three state management modes, determined by props on `GraphProvider`: + +1. **Uncontrolled** β€” Pass `elements`/`links` as initial data. The internal `GraphStore` owns state. No `onElementsChange`/`onLinksChange` callbacks. +2. **React-controlled** β€” Pass `onElementsChange`/`onLinksChange`. Changes flow through React state (`useState` setter pattern). +3. **External-store-controlled** β€” Pass `externalStore` prop. Integrates with Redux, Zustand, Jotai, etc. + +### Component Tree + +``` +GraphProvider (provides GraphStoreContext) + └── Paper (provides PaperStoreContext, renders canvas) + β”œβ”€β”€ Element portals (React components rendered into SVG via foreignObject) + └── Link portals (React components for link rendering) +``` + +### Key Source Directories + +- **`src/components/`** β€” `GraphProvider` and `Paper` (the two public components), plus internal `Link`, `Port`, `TextNode`, `Highlighters` +- **`src/hooks/`** β€” Public hooks (`useGraph`, `usePaper`, `useElements`, `useLinks`, `useElement`, `useCellActions`, `useNodeSize`, `usePaperEvents`, etc.) +- **`src/store/`** β€” `GraphStore` (central state: elements, links, sync) and `PaperStore` (per-paper view state, portals, element sizing) +- **`src/models/`** β€” `ReactElement` (empty markup, React renders via portal), `ReactLink`, `ReactPaper` (extended `dia.Paper` with React view lifecycle) +- **`src/state/`** β€” Selectors (`mapElementAttributesToData`, `mapLinkAttributesToData`) and sync logic between JointJS models and React state +- **`src/types/`** β€” `GraphElement`, `GraphLink`, `PaperProps`, event types +- **`src/utils/`** β€” Joint JSXβ†’markup conversion, event handling, scheduling, equality checks +- **`src/theme/`** β€” Default link theme and marker presets (arrow, circle, diamond, etc.) + +### Data Flow + +Elements and links are `Record` objects. GraphStore syncs these with JointJS `dia.Graph` models bidirectionally: + +1. **React β†’ JointJS:** `updateGraph()` diffs incoming records against current graph cells +2. **JointJS β†’ React:** `stateSync` listens to model `change:*` events, maps attributes back to data via selectors, and flushes updates + +### Rendering Pipeline + +`Paper` renders elements via React portals into SVG `` nodes managed by JointJS. `ReactElement` has empty `markup` β€” all visual content comes from the `renderElement` callback prop. Large graphs (>100 cells) use `useDeferredValue` for performance. + +### Build Output + +`build.ts` uses esbuild to produce: +- `dist/cjs/` β€” CommonJS (bundled) +- `dist/esm/` β€” ES modules (bundled) +- `dist/types/` β€” TypeScript declarations (via `tsc --project tsconfig.types.json`) + +External deps (react, react-dom, @joint/core, use-sync-external-store) are excluded from bundles. + +## Test Setup + +- **Framework:** Jest 30 with jsdom environment +- **Transform:** @swc/jest for TypeScript/JSX +- **Test location:** `src/**/__tests__/*.test.ts(x)` +- **Mocks:** `__mocks__/jest-setup.ts` provides SVG DOM stubs (SVGPathElement, SVGAngle, SVGMatrix, ResizeObserver) +- **Module aliases:** `@joint/react` β†’ `src/index.ts`, `src/*` β†’ `/src/*` + +## Key Patterns + +- **Portal-based rendering:** React components render into SVG foreignObject nodes owned by JointJS Paper +- **Context hierarchy:** `GraphStoreContext` β†’ `PaperStoreContext` β†’ `CellIdContext` (nested providers) +- **Selector subscriptions:** Hooks accept `selector` and `equalityFn` params for fine-grained reactivity (similar to Redux `useSelector`) +- **Imperative escape hatch:** `Paper` forwards ref to expose `PaperStore` for imperative operations +- **Scheduler-based batching:** State flushes are batched via a microtask scheduler to avoid excessive re-renders