diff --git a/packages/joint-react/.storybook/decorators/with-strict-mode.tsx b/packages/joint-react/.storybook/decorators/with-strict-mode.tsx index e3f4b719d1..6bdbde9704 100644 --- a/packages/joint-react/.storybook/decorators/with-strict-mode.tsx +++ b/packages/joint-react/.storybook/decorators/with-strict-mode.tsx @@ -1,4 +1,16 @@ +import { StrictMode } from 'react'; + +/** + * Wraps a story in `React.StrictMode` so double-invoked renders / effects and + * cleanup regressions surface during development (especially on React 19). + * @param Story - The story component to render. + * @returns The story wrapped in `StrictMode`. + */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function withStrictMode(Story: any) { - return ; + return ( + + + + ); } diff --git a/packages/joint-react/CHANGELOG.md b/packages/joint-react/CHANGELOG.md new file mode 100644 index 0000000000..d6cfe59357 --- /dev/null +++ b/packages/joint-react/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +All notable changes to `@joint/react` are documented here. The format is based +on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + diff --git a/packages/joint-react/__mocks__/jest-setup.ts b/packages/joint-react/__mocks__/jest-setup.ts index 29102e11ef..c19a939925 100644 --- a/packages/joint-react/__mocks__/jest-setup.ts +++ b/packages/joint-react/__mocks__/jest-setup.ts @@ -46,6 +46,11 @@ Object.defineProperty(globalThis, 'SVGAngle', { }); beforeEach(() => { + // Node (SSR) test environments have no DOM — skip the browser API mocks so + // server-rendering tests (`@jest-environment node`) can run with this setup. + if (globalThis.SVGSVGElement === undefined) { + return; + } /** * @see https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver */ diff --git a/packages/joint-react/package.json b/packages/joint-react/package.json index c79a5d6bb7..10e3d6faac 100644 --- a/packages/joint-react/package.json +++ b/packages/joint-react/package.json @@ -45,12 +45,15 @@ "./scripts/*": "./scripts/*.ts", "./styles.css": "./src/css/styles.css" }, - "sideEffects": false, + "sideEffects": [ + "**/*.css" + ], "files": [ "dist", "README.md", + "CHANGELOG.md", "LICENSE", - "src" + "src/css" ], "homepage": "https://jointjs.com", "author": { @@ -62,7 +65,6 @@ "Samuel (https://github.com/samuelgja)" ], "scripts": { - "prepublishOnly": "echo \"Publishing via NPM is not allowed!\" && exit 1", "prepack": "yarn test && yarn build", "build": "rollup -c rollup.config.ts --configPlugin esbuild && tsc --project tsconfig.types.json", "build-storybook": "storybook build", @@ -144,8 +146,8 @@ "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "volta": { "node": "22.14.0", diff --git a/packages/joint-react/src/__tests__/ssr-client-handoff.test.tsx b/packages/joint-react/src/__tests__/ssr-client-handoff.test.tsx new file mode 100644 index 0000000000..95b7c07474 --- /dev/null +++ b/packages/joint-react/src/__tests__/ssr-client-handoff.test.tsx @@ -0,0 +1,84 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +/** + * SSR → client handoff: a tree authored to be server-rendered is hydrated on + * the client, and we assert the full interactive surface works afterwards — + * Paper mounts its real canvas, JointJS events fire, the normalized event + * props invoke their handlers, and React state updates re-render. + * + * (jsdom = the client/browser side of the handoff. The server side — that + * `GraphProvider` renders to HTML at all — is covered by `ssr.test.tsx`.) + */ +import { useState } from 'react'; +import { act, waitFor } from '@testing-library/react'; +import { hydrateRoot } from 'react-dom/client'; +import type { dia } from '@joint/core'; +import { GraphProvider } from '../components/graph/graph-provider'; +import { Paper } from '../components/paper/paper'; +import { ELEMENT_MODEL_TYPE } from '../models/element-model'; +import type { CellRecord } from '../types/cell.types'; + +const CELLS: readonly CellRecord[] = [ + { id: '1', type: ELEMENT_MODEL_TYPE, position: { x: 0, y: 0 }, size: { width: 50, height: 50 } } as CellRecord, +]; +const renderRect = () => ; +const fakeEvent = {} as dia.Event; + +it('hydrates a server tree and Paper is fully interactive on the client (events + state)', async () => { + let paper: dia.Paper | null = null; + const setPaper = (instance: dia.Paper | null) => { + if (instance) paper = instance; + }; + + function App() { + // React state driven by a JointJS paper event — proves the full loop. + const [clicks, setClicks] = useState(0); + return ( + + {clicks} + setClicks((value) => value + 1)} + /> + + ); + } + + const container = document.createElement('div'); + document.body.append(container); + + // Hydrate on the client (as it would after SSR delivered the markup). + await act(async () => { + hydrateRoot(container, ); + }); + + // 1) Paper mounted its real canvas on the client. + await waitFor(() => { + expect(container.querySelector('svg')).toBeTruthy(); + expect(paper).not.toBeNull(); + }); + + const livePaper = paper as unknown as dia.Paper; + + // 2) The element view exists (JointJS rendered the cell). + const view = livePaper.findViewByModel(livePaper.model.getCell('1')); + expect(view).toBeTruthy(); + + // 3) A paper event fires → normalized handler runs → React state updates → re-render. + act(() => { + livePaper.trigger('element:pointerclick', view, fakeEvent, 5, 5); + }); + await waitFor(() => { + expect(container.querySelector('[data-testid="clicks"]')?.textContent).toBe('1'); + }); + + // 4) It keeps working — second event, state advances again. + act(() => { + livePaper.trigger('element:pointerclick', view, fakeEvent, 6, 6); + }); + await waitFor(() => { + expect(container.querySelector('[data-testid="clicks"]')?.textContent).toBe('2'); + }); +}); diff --git a/packages/joint-react/src/__tests__/ssr.test.tsx b/packages/joint-react/src/__tests__/ssr.test.tsx new file mode 100644 index 0000000000..b5d3d4bcc8 --- /dev/null +++ b/packages/joint-react/src/__tests__/ssr.test.tsx @@ -0,0 +1,86 @@ +/** + * @jest-environment node + * + * Server-side rendering smoke tests, run in a real Node environment (no DOM). + * Contract: + * - `GraphProvider` (data + context) renders on the server, including children + * and data read via hooks like `useCells`. + * - `Paper` (DOM rendering) degrades gracefully to its host element — it must + * not crash the server render. + */ +import { renderToString } from 'react-dom/server'; +import { GraphProvider } from '../components/graph/graph-provider'; +import { Paper } from '../components/paper/paper'; +import { useCells } from '../hooks/use-cells'; +import { ELEMENT_MODEL_TYPE } from '../models/element-model'; +import type { CellRecord } from '../types/cell.types'; + +const CELLS: readonly CellRecord[] = [ + { + id: 'a', + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 50, height: 50 }, + } as CellRecord, + { + id: 'b', + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 50, height: 50 }, + } as CellRecord, +]; + +function CellIds() { + const cells = useCells(); + return ( +
    + {cells.map((cell) => ( +
  • {String(cell.id)}
  • + ))} +
+ ); +} + +describe('SSR: node environment, no DOM', () => { + it('is a real server environment (no DOM globals)', () => { + expect(globalThis.document).toBeUndefined(); + expect(globalThis.window).toBeUndefined(); + expect(globalThis.ResizeObserver).toBeUndefined(); + }); + + it('GraphProvider renders its children on the server', () => { + let html = ''; + expect(() => { + html = renderToString( + +
server-child
+
+ ); + }).not.toThrow(); + expect(html).toContain('server-child'); + }); + + it('exposes graph data to hooks during SSR (useCells works on the server)', () => { + const html = renderToString( + + + + ); + expect(html).toContain('
  • a
  • '); + expect(html).toContain('
  • b
  • '); + }); + + it('Paper degrades gracefully on the server (renders host element, no crash)', () => { + let html = ''; + expect(() => { + html = renderToString( + + {/* eslint-disable-next-line react-perf/jsx-no-new-function-as-prop */} + } /> + + ); + }).not.toThrow(); + // The host container renders; the SVG/portal content is client-only. + expect(html).toContain(' => + store ?? + new GraphStore({ + graph, + cellNamespace, + cellModel, + initialCells: cells ?? initialCells ?? [], + autoSizeOrigin, + }); + + // Client: the store is owned by a layout-effect lifecycle (StrictMode-safe + // create/cleanup). `isReady` stays false on the server because layout effects + // never run there — that is handled by the SSR branch below. const { isReady, ref } = useImperativeApi, dia.Graph>( { instanceSelector: (instance) => instance.graph, forwardedRef, onLoad() { - const graphStore = - store ?? - new GraphStore({ - graph, - cellNamespace, - cellModel, - initialCells: cells ?? initialCells ?? [], - autoSizeOrigin, - }); + const graphStore = buildStore(); return { cleanup() { if (store) return; @@ -129,7 +141,7 @@ function GraphBase(props: GraphProviderBaseInternalProps) { [] ); - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { if (!isReady) return; ref.current.setOnIncrementalCellsChange((changeSet) => { onIncrementalCellsChange?.(changeSet); @@ -147,6 +159,13 @@ function GraphBase(props: GraphProviderBaseInternalProps) { }, [isReady, onIncrementalCellsChange, onCellsChange, ref, isControlled, cells]); if (!isReady) { + // Server render: provide a synchronous, per-request store so children and + // data hooks (`useCells`, ...) render to HTML. `GraphStore` is pure data + // (no DOM), so this is SSR-safe; `` degrades to its host element. + // On the client this branch is never reached once the layout effect runs. + if (IS_SERVER) { + return {children}; + } return null; } diff --git a/packages/joint-react/src/components/paper/__tests__/paper-events.test.tsx b/packages/joint-react/src/components/paper/__tests__/paper-events.test.tsx new file mode 100644 index 0000000000..9311d46302 --- /dev/null +++ b/packages/joint-react/src/components/paper/__tests__/paper-events.test.tsx @@ -0,0 +1,376 @@ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +import { useCallback, useState } from 'react'; +import { render, waitFor, act } from '@testing-library/react'; +import { mvc, type dia } from '@joint/core'; +import { Paper } from '../paper'; +import { GraphProvider } from '../../graph/graph-provider'; +import { ELEMENT_MODEL_TYPE } from '../../../models/element-model'; +import type { CellRecord } from '../../../types/cell.types'; + +const CELLS: readonly CellRecord[] = [ + { + id: '1', + type: ELEMENT_MODEL_TYPE, + position: { x: 0, y: 0 }, + size: { width: 50, height: 50 }, + } as CellRecord, +]; + +const renderRectElement = () => ; +const fakeEvent = {} as dia.Event; + +interface HarnessProps { + readonly setRef: (paper: dia.Paper | null) => void; +} + +/** + * Mounts a paper and resolves once the `dia.Paper` instance is available. + * @param ui - Factory receiving a ref callback; returns the tree under test. + * @returns The created paper. + */ +async function mountPaper( + ui: (ref: (paper: dia.Paper | null) => void) => React.ReactElement +): Promise<{ paper: dia.Paper }> { + let current: dia.Paper | null = null; + const setRef = (paper: dia.Paper | null) => { + if (paper) current = paper; + }; + render({ui(setRef)}); + await waitFor(() => { + expect(current).not.toBeNull(); + }); + return { paper: current as unknown as dia.Paper }; +} + +const clickTestId = (testId: string) => + act(() => { + (document.querySelector(`[data-testid="${testId}"]`) as HTMLButtonElement).click(); + }); + +describe('Paper normalized event props', () => { + it('fires a normalized handler with a context object', async () => { + const onBlankContextMenu = jest.fn(); + const { paper } = await mountPaper((ref) => ( + + )); + + act(() => { + paper.trigger('blank:contextmenu', fakeEvent, 10, 20); + }); + + expect(onBlankContextMenu).toHaveBeenCalledTimes(1); + expect(onBlankContextMenu).toHaveBeenCalledWith( + expect.objectContaining({ paper, graph: paper.model, event: fakeEvent, x: 10, y: 20 }) + ); + }); + + it('does NOT re-subscribe across unrelated re-renders when handlers are stable refs', async () => { + const listenToSpy = jest.spyOn(mvc.Listener.prototype, 'listenTo'); + const bindCount = (eventName: string) => + listenToSpy.mock.calls.filter((call) => String(call[1]) === eventName).length; + + const handler = jest.fn(); + function Harness({ setRef }: Readonly) { + const [, force] = useState(0); + // eslint-disable-next-line sonarjs/no-nested-functions + const rerender = () => force((n) => n + 1); + // Stable reference across renders — this is the documented contract. + const onBlankContextMenu = useCallback(handler, []); + return ( + <> +