diff --git a/package.json b/package.json index 714a6123fd..ceedeb7ecd 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "workspaces": [ "./packages/*", + "./packages/joint-svg-shim/examples/*", "./examples/*" ], "volta": { @@ -35,5 +36,9 @@ "npm": "11.2.0", "yarn": "4.7.0" }, + "resolutions": { + "react": "19.2.5", + "react-dom": "19.2.5" + }, "packageManager": "yarn@4.7.0" } 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/examples/build.ts b/packages/joint-react/examples/build.ts new file mode 100644 index 0000000000..cbc583ec72 --- /dev/null +++ b/packages/joint-react/examples/build.ts @@ -0,0 +1,25 @@ +/* eslint-disable no-console -- build script logs bundle output to stdout */ +import tailwind from 'bun-plugin-tailwind'; +import { rm } from 'node:fs/promises'; +import path from 'node:path'; + +const outdir = path.join(process.cwd(), 'dist'); +await rm(outdir, { recursive: true, force: true }); + +const entrypoints = [...new Bun.Glob('src/**/*.html').scanSync()]; + +const result = await Bun.build({ + entrypoints, + outdir, + plugins: [tailwind], + minify: true, + target: 'browser', + sourcemap: 'linked', + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, +}); + +for (const output of result.outputs) { + console.log(` ${path.relative(process.cwd(), output.path)} ${(output.size / 1024).toFixed(1)} KB`); +} diff --git a/packages/joint-react/examples/bun-environment.d.ts b/packages/joint-react/examples/bun-environment.d.ts new file mode 100644 index 0000000000..94c9f93a62 --- /dev/null +++ b/packages/joint-react/examples/bun-environment.d.ts @@ -0,0 +1,19 @@ +// Generated by `bun init` + +declare module '*.svg' { + /** + * A path to the SVG file + */ + const path: `${string}.svg`; + export = path; +} + +declare module '*.css' {} + +declare module '*.module.css' { + /** + * A record of class names to their corresponding CSS module classes + */ + const classes: { readonly [key: string]: string }; + export = classes; +} diff --git a/packages/joint-react/jest.config.js b/packages/joint-react/jest.config.js index 90a71fd35e..3434844eee 100644 --- a/packages/joint-react/jest.config.js +++ b/packages/joint-react/jest.config.js @@ -28,6 +28,9 @@ export default { ], moduleNameMapper: { '^.+\\.css$': '/__mocks__/style-mock.ts', // Mock CSS files + '^@joint/svg-shim/install$': '/../joint-svg-shim/src/install.ts', + '^@joint/svg-shim/flag$': '/../joint-svg-shim/src/dom-shim-flag.ts', + '^@joint/svg-shim$': '/../joint-svg-shim/src/index.ts', '^@joint/react$': '/src/index.ts', '^src/(.*)$': '/src/$1', '^storybook-config/(.*)$': '/.storybook/$1', diff --git a/packages/joint-react/package.json b/packages/joint-react/package.json index c79a5d6bb7..693c75f060 100644 --- a/packages/joint-react/package.json +++ b/packages/joint-react/package.json @@ -31,6 +31,11 @@ "import": "./dist/esm/internal.js", "require": "./dist/cjs/internal.js" }, + "./server": { + "types": "./dist/types/server/index.d.ts", + "import": "./dist/esm/server/index.js", + "require": "./dist/cjs/server/index.js" + }, "./presets": { "types": "./dist/types/presets/index.d.ts", "import": "./dist/esm/presets/index.js", @@ -45,12 +50,17 @@ "./scripts/*": "./scripts/*.ts", "./styles.css": "./src/css/styles.css" }, - "sideEffects": false, + "sideEffects": [ + "**/*.css", + "./dist/esm/server/index.js", + "./dist/cjs/server/index.js" + ], "files": [ "dist", "README.md", + "CHANGELOG.md", "LICENSE", - "src" + "src/css" ], "homepage": "https://jointjs.com", "author": { @@ -62,7 +72,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", @@ -80,6 +89,7 @@ "@chromatic-com/storybook": "^5.0.0", "@joint/eslint-config": "workspace:*", "@joint/layout-directed-graph": "workspace:*", + "@joint/svg-shim": "workspace:*", "@reduxjs/toolkit": "^2.11.0", "@rollup/plugin-commonjs": "^28.0.6", "@rollup/plugin-node-resolve": "^16.0.1", @@ -94,6 +104,7 @@ "@testing-library/react": "^16.0.1", "@testing-library/react-hooks": "^8.0.1", "@types/jest": "30.0.0", + "@types/jsdom": "21.1.7", "@types/node": "^24.3.0", "@types/react": "19.1.12", "@types/react-dom": "19.1.9", @@ -107,6 +118,7 @@ "jest": "30.1.2", "jest-environment-jsdom": "30.1.2", "jotai": "^2.15.2", + "jsdom": "26.1.0", "knip": "5.63.0", "peerjs": "^1.5.5", "prettier": "3.3.3", @@ -144,8 +156,14 @@ "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "@joint/svg-shim": ">=0.0.1", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@joint/svg-shim": { + "optional": true + } }, "volta": { "node": "22.14.0", diff --git a/packages/joint-react/rollup.config.ts b/packages/joint-react/rollup.config.ts index 28be647cda..c36a8d9ced 100644 --- a/packages/joint-react/rollup.config.ts +++ b/packages/joint-react/rollup.config.ts @@ -1,12 +1,21 @@ import { createRollupConfig } from './scripts/create-rollup-config'; export default createRollupConfig({ - entries: ['src/index.ts', 'src/internal.ts', 'src/presets/index.ts', 'src/stories.ts'], + entries: [ + 'src/index.ts', + 'src/internal.ts', + 'src/presets/index.ts', + 'src/stories.ts', + 'src/server/index.ts', + ], external: [ 'react', 'react-dom', 'use-sync-external-store', '@joint/core', '@joint/layout-directed-graph', + 'node:module', + 'node:path', + 'jsdom', ], }); 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-paper-handoff.test.tsx b/packages/joint-react/src/__tests__/ssr-paper-handoff.test.tsx new file mode 100644 index 0000000000..4d7bd77d0d --- /dev/null +++ b/packages/joint-react/src/__tests__/ssr-paper-handoff.test.tsx @@ -0,0 +1,93 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +/** + * The SSR round-trip for the automatic `` server render: + * 1. on the server the full diagram (nodes + `renderElement` content) is in the + * HTML — first paint with no client JS, and + * 2. on the client the same tree hydrates and the live, interactive paper takes + * over (events drive React state). + * + * `../server/register` registers the server renderer; the DOM-shim flag is + * toggled to reproduce the server→client boundary. + */ +import { useState } from 'react'; +import { act, waitFor } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; +import { hydrateRoot } from 'react-dom/client'; +import type { dia } from '@joint/core'; +import '../server'; +import { GraphProvider } from '../components/graph/graph-provider'; +import { Paper } from '../components/paper/paper'; +import { DOM_SHIM_FLAG } from '../utils/ssr'; +import type { CellRecord } from '../types/cell.types'; + +const CELLS: readonly CellRecord[] = [ + { id: '1', type: 'element', position: { x: 10, y: 10 }, size: { width: 60, height: 40 }, data: { label: 'A' } } as CellRecord, +]; +const renderRect = () => ; +const fakeEvent = {} as dia.Event; + +function App({ onClick }: Readonly<{ onClick?: () => void }>) { + return ( + + + + ); +} + +it('server-renders the full diagram (nodes + renderElement) as HTML', () => { + const globalScope = globalThis as Record; + globalScope[DOM_SHIM_FLAG] = true; + let html = ''; + try { + html = renderToString(); + } finally { + Reflect.deleteProperty(globalScope, DOM_SHIM_FLAG); + } + expect(html).toContain('joint-cells'); + expect(html).toContain('translate(10,10)'); // node position from the model + expect(html).toContain(' { + let paper: dia.Paper | null = null; + const setPaper = (instance: dia.Paper | null) => { + if (instance) paper = instance; + }; + + function ClientApp() { + const [clicks, setClicks] = useState(0); + return ( + + {clicks} + setClicks((value) => value + 1)} + /> + + ); + } + + const container = document.createElement('div'); + document.body.append(container); + await act(async () => { + hydrateRoot(container, ); + }); + + await waitFor(() => { + expect(paper).not.toBeNull(); + expect(container.querySelector('.joint-cells')).toBeTruthy(); + }); + + const livePaper = paper as unknown as dia.Paper; + const view = livePaper.findViewByModel(livePaper.model.getCell('1')); + expect(view).toBeTruthy(); + act(() => { + livePaper.trigger('element:pointerclick', view, fakeEvent, 5, 5); + }); + await waitFor(() => { + expect(container.querySelector('[data-testid="clicks"]')?.textContent).toBe('1'); + }); +}); 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 +136,7 @@ function GraphBase(props: GraphProviderBaseInternalProps) { [] ); - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { if (!isReady) return; ref.current.setOnIncrementalCellsChange((changeSet) => { onIncrementalCellsChange?.(changeSet); @@ -147,6 +154,17 @@ 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; `` renders the diagram tree (or just + // its host element). On the client this branch is never reached once the + // layout effect runs. Evaluated at render time so it also fires when the + // server DOM shim has installed a headless `document`. + if (isServerEnvironment()) { + return ( + {children} + ); + } return null; } diff --git a/packages/joint-react/src/components/paper/default-render-element.tsx b/packages/joint-react/src/components/paper/default-render-element.tsx new file mode 100644 index 0000000000..a8e17049d4 --- /dev/null +++ b/packages/joint-react/src/components/paper/default-render-element.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react'; +import { HTMLBox } from '../html-box'; +import type { RenderElement } from './paper.types'; + +/** + * Default element renderer used by `` when no `renderElement` is given: + * a themed `HTMLBox` showing `data.label`. Shared by the client portal renderer + * and the server tree builder so the first paint matches on both. + * @param data - the element's `data` slice. + * @returns the default element content. + */ +export const defaultRenderElement: RenderElement> = (data) => { + const label = (data as { label?: ReactNode } | undefined)?.label; + return {label}; +}; diff --git a/packages/joint-react/src/components/paper/paper.tsx b/packages/joint-react/src/components/paper/paper.tsx index 58b871d256..4c30929b49 100644 --- a/packages/joint-react/src/components/paper/paper.tsx +++ b/packages/joint-react/src/components/paper/paper.tsx @@ -1,8 +1,9 @@ import type { dia } from '@joint/core'; -import { forwardRef, useImperativeHandle, useRef } from 'react'; +import { forwardRef, useImperativeHandle, useMemo, useRef, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; import { PaperStoreContext } from '../../context'; import { useCreatePortalPaper } from '../../hooks/use-create-portal-paper'; +import { useServerPaperTree } from '../../hooks/use-server-paper-tree'; import type { PaperProps } from './paper.types'; import { DEFAULT_PAPER_ID } from '../../models/react-paper'; @@ -20,6 +21,18 @@ function PaperBase( const paperHTMLElementRef = useRef(null); const id = props.id ?? DEFAULT_PAPER_ID; const isExternalPaper = !!externalPaper; + // On the server (when `@joint/react/server` is loaded) build the full diagram + // as a React tree from the GraphProvider graph — so a plain + // `` renders a + // complete diagram. `undefined` on the client (the live paper mounts there). + const serverTree = useServerPaperTree({ paperId: id, isExternalPaper, props }); + // The server SVG is `position: absolute; inset: 0`, so the host needs + // `position: relative` to contain it — without JS the live paper isn't there + // to set it. (The live paper sets it itself on the client.) + const hostStyle = useMemo( + () => (serverTree == null ? style : { ...style, position: 'relative' }), + [serverTree, style] + ); const { paperRef, paperStore, isReady, content } = useCreatePortalPaper({ ...props, elementRef: isExternalPaper ? undefined : paperHTMLElementRef, @@ -40,16 +53,20 @@ function PaperBase( if (isExternalPaper) { return ( - {isReady && content} + {isReady ? content : null} {portaledChildren} ); } + // On the server the host renders the diagram tree (so `renderToString` emits + // the full SVG). On the client `serverTree` is `undefined`, so React reconciles + // it away and `useCreatePortalPaper` mounts the live paper into the host; + // portal content renders there. return ( -
    - {isReady && content} +
    + {serverTree ?? (isReady ? content : null)}
    {portaledChildren} diff --git a/packages/joint-react/src/components/paper/paper.types.ts b/packages/joint-react/src/components/paper/paper.types.ts index 4363da1141..7f5d07e2d6 100644 --- a/packages/joint-react/src/components/paper/paper.types.ts +++ b/packages/joint-react/src/components/paper/paper.types.ts @@ -247,7 +247,7 @@ export interface PaperProps extends PortalPaperOptions, PropsWithChildren, Norma * @example * Example with `global component`: * ```tsx - * type BaseElementWithData = InferElement + * type BaseElementWithData = { label: string } * function RenderElement({ label }: BaseElementWithData) { * return {label} * } @@ -256,7 +256,7 @@ export interface PaperProps extends PortalPaperOptions, PropsWithChildren, Norma * Example with `local component`: * ```tsx * - type BaseElementWithData = InferElement + type BaseElementWithData = { label: string } const renderElement: RenderElement = useCallback( (element) => {element.label}, [] diff --git a/packages/joint-react/src/components/paper/render-element/paper-html-container.tsx b/packages/joint-react/src/components/paper/render-element/paper-html-container.tsx index c008188ed3..1934340472 100644 --- a/packages/joint-react/src/components/paper/render-element/paper-html-container.tsx +++ b/packages/joint-react/src/components/paper/render-element/paper-html-container.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo, useRef, type CSSProperties } from 'react'; +import { memo, useLayoutEffect, useMemo, useRef, type CSSProperties } from 'react'; import { usePaper } from '../../../hooks'; import { mvc, V } from '@joint/core'; import { createPortal } from 'react-dom'; @@ -11,7 +11,7 @@ function Component({ onSetElement }: Readonly) { const { paper } = usePaper(); const divRef = useRef(null); - useEffect(() => { + useLayoutEffect(() => { if (!paper || !divRef.current) { return; } @@ -36,7 +36,8 @@ function Component({ onSetElement }: Readonly) { return () => { controller.stopListening(); }; - }, [divRef, onSetElement, paper]); + // `divRef` is a ref — stable identity, intentionally not a dependency. + }, [onSetElement, paper]); const style = useMemo( (): CSSProperties => ({ diff --git a/packages/joint-react/src/hooks/use-are-elements-measured.ts b/packages/joint-react/src/hooks/use-are-elements-measured.ts index 998d68c011..7ff72109cc 100644 --- a/packages/joint-react/src/hooks/use-are-elements-measured.ts +++ b/packages/joint-react/src/hooks/use-are-elements-measured.ts @@ -16,6 +16,8 @@ export function useAreElementsMeasured() { }, () => { return measureState.get() > 0; - } + }, + // Server snapshot: nothing is measured without a DOM, so report unmeasured. + () => false ); } diff --git a/packages/joint-react/src/hooks/use-create-portal-paper.tsx b/packages/joint-react/src/hooks/use-create-portal-paper.tsx index 2ee3f80074..5037fe488d 100644 --- a/packages/joint-react/src/hooks/use-create-portal-paper.tsx +++ b/packages/joint-react/src/hooks/use-create-portal-paper.tsx @@ -22,7 +22,7 @@ import type { LinkRecord } from '../types/cell.types'; import type { PaperStore } from '../store'; import { ReactPaper } from '../models/react-paper'; import type { PaperProps, PortalPaperOptions, RenderLink } from '../components/paper/paper.types'; -import { HTMLBox } from '../components/html-box'; +import { defaultRenderElement } from '../components/paper/default-render-element'; import { mapLinkToAttributes } from '../state/data-mapping'; import type { CanConnectOptions } from '../presets/can-connect'; @@ -138,7 +138,7 @@ function createDefaultLinkCallback(defaultLink: PortalPaperOptions['defaultLink' */ function LinkItem({ portalElement, - renderLink, + renderLink: RenderLink, }: { readonly portalElement: SVGElement | HTMLElement; readonly renderLink: RenderLink; @@ -159,21 +159,10 @@ function LinkItem({ if (!portalElement || id === undefined) { return null; } - const linkContent = renderLink(data); + const linkContent = ; return createPortal(linkContent, portalElement); } -/** - * The default element if the user doesn't provide a renderElement function. - * Renders `data.label` inside a DefaultHTMLHost. - * @param data - the element's user data slice - * @returns A JSX element rendering the label inside a DefaultHTMLHost with default styling. - */ -const defaultRenderElement = (data: unknown) => { - const label = (data as { label?: string } | undefined)?.label; - return {label}; -}; - /** * Creates and manages a React-backed JointJS paper instance lifecycle. * @param options - Hook options with paper settings and behavior overrides. @@ -304,6 +293,14 @@ export function useCreatePortalPaper( useLayoutEffect(() => { const hostElementForCreation = elementRef?.current; + // Clear any server-rendered SSR markup from the host before the live paper + // mounts, so the JointJS-built SVG replaces it rather than stacking on top. + // React keeps the host's `dangerouslySetInnerHTML` value stable, so it does + // not re-insert the markup after this. + if (hostElementForCreation) { + hostElementForCreation.replaceChildren(); + } + const { paperStore, remove } = addPaper(id, { paperOptions: { ...paperOptions, diff --git a/packages/joint-react/src/hooks/use-isomorphic-layout-effect.ts b/packages/joint-react/src/hooks/use-isomorphic-layout-effect.ts new file mode 100644 index 0000000000..a50a992702 --- /dev/null +++ b/packages/joint-react/src/hooks/use-isomorphic-layout-effect.ts @@ -0,0 +1,13 @@ +import { useEffect, useLayoutEffect } from 'react'; +import { isServerEnvironment } from '../utils/ssr'; + +/** + * `useLayoutEffect` in the browser, `useEffect` on the server. + * + * `useLayoutEffect` logs a warning during server rendering ("useLayoutEffect + * does nothing on the server") because layout effects never run there. This + * alias swaps to `useEffect` on the server (including when the DOM shim has + * installed a headless `document`), silencing the warning while preserving + * synchronous, pre-paint timing in the browser. + */ +export const useIsomorphicLayoutEffect = isServerEnvironment() ? useEffect : useLayoutEffect; diff --git a/packages/joint-react/src/hooks/use-server-paper-tree.ts b/packages/joint-react/src/hooks/use-server-paper-tree.ts new file mode 100644 index 0000000000..e234d54fd2 --- /dev/null +++ b/packages/joint-react/src/hooks/use-server-paper-tree.ts @@ -0,0 +1,86 @@ +import { useMemo, type CSSProperties, type ReactNode } from 'react'; +import type { dia } from '@joint/core'; +import { useGraphStore } from './use-graph-store'; +import { isServerEnvironment } from '../utils/ssr'; +import { getServerPaperRenderer } from '../utils/server-paper-renderer'; +import type { PaperProps } from '../components/paper/paper.types'; + +/** Reads a CSS length as a number, or `undefined` when it is not a plain number. */ +function toNumber(value: CSSProperties['width']): number | undefined { + return typeof value === 'number' ? value : undefined; +} + +/** Options for {@link useServerPaperTree}. */ +export interface ServerPaperTreeOptions { + /** Effective paper id. */ + readonly paperId: string; + /** Whether the paper is externally managed (skip server generation). */ + readonly isExternalPaper: boolean; + /** The Paper props (renderers, style, paper options). */ + readonly props: Readonly; +} + +/** + * Builds the diagram React tree for `` on the server. + * + * On the server — and only when `@joint/react/server` has registered + * the renderer — this builds the full diagram (positioned nodes, links, and + * `renderElement` content) from the `GraphProvider` graph, so a plain + * `` renders a complete + * diagram with no extra wiring. The returned tree is rendered by the outer + * `renderToString`, so `renderElement` runs natively in the same pass. + * + * Returns `undefined` on the client, where the live paper mounts normally. + * @param options - paper id, external flag, and props. + * @returns the diagram React tree, or `undefined`. + */ +export function useServerPaperTree(options: ServerPaperTreeOptions): ReactNode { + const { paperId, isExternalPaper, props } = options; + const graphStore = useGraphStore(); + + return useMemo(() => { + if (isExternalPaper || !isServerEnvironment()) { + return; + } + const renderer = getServerPaperRenderer(); + if (!renderer) { + return; + } + + // `linkRouting` is a bundle of `dia.Paper.Options` (router/connector). Spread + // it like the client (`useCreatePortalPaper`) so links route identically on + // the server — otherwise the JS-disabled first paint would differ. + const paperOptions: Partial = { ...props.options, ...props.linkRouting }; + if (props.drawGrid !== undefined) { + paperOptions.drawGrid = props.drawGrid; + } + if (props.gridSize !== undefined) { + paperOptions.gridSize = props.gridSize; + } + if (props.background !== undefined) { + paperOptions.background = props.background; + } + + try { + const { tree } = renderer({ + graph: graphStore.graph, + graphStore, + renderElement: props.renderElement, + renderLink: props.renderLink, + portalSelector: props.portalSelector, + paperId, + width: toNumber(props.style?.width), + height: toNumber(props.style?.height), + paperOptions, + }); + return tree; + } catch { + // SSR is best-effort: if building the server tree throws, degrade to a + // client-mounted paper (return `undefined`) instead of failing the whole + // `renderToString`. The client hydrates the live paper as usual. + return; + } + // Server rendering is one-shot — generate once per render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/packages/joint-react/src/server/__tests__/react-ssr.test.tsx b/packages/joint-react/src/server/__tests__/react-ssr.test.tsx new file mode 100644 index 0000000000..c5e04f4fd6 --- /dev/null +++ b/packages/joint-react/src/server/__tests__/react-ssr.test.tsx @@ -0,0 +1,214 @@ +/* eslint-disable react-perf/jsx-no-new-function-as-prop */ +/* eslint-disable react-perf/jsx-no-new-object-as-prop */ +/* eslint-disable react-perf/jsx-no-new-array-as-prop */ +/** + * @jest-environment node + * + * The headline SSR flow: a plain `` + * tree, rendered with `renderToString`, produces a COMPLETE diagram HTML on the + * server — positioned nodes, links, and `renderElement` content — with no + * `ssrMarkup` prop and no separate `renderPaperToStaticMarkup` call. + * + * `..` is imported first: it installs the DOM shim (before `@joint/core` + * is evaluated by the component imports) and registers the server paper renderer + * that `` uses automatically. + */ +import '..'; +import { renderToString } from 'react-dom/server'; +import { GraphProvider } from '../../components/graph/graph-provider'; +import { Paper } from '../../components/paper/paper'; +import { useCell } from '../../hooks/use-cell'; +import { useCellId } from '../../hooks/use-cell-id'; +import { useCells } from '../../hooks/use-cells'; +import { linkRoutingOrthogonal } from '../../presets/link-routing'; +import type { CellRecord } from '../../types/cell.types'; + +const LINK_LINE_TAG = /]*jj-link-line[^>]*>/; +const PATH_D_ATTRIBUTE = /\bd="([^"]*)"/; + +/** Extracts the `d` of the rendered link line (`jj-link-line`) from SSR HTML. */ +function linkLinePath(html: string): string { + const tag = LINK_LINE_TAG.exec(html)?.[0] ?? ''; + return PATH_D_ATTRIBUTE.exec(tag)?.[1] ?? ''; +} + +/** Counts path segment commands (`L`/`C`) — more segments means more bends. */ +function segmentCount(pathData: string): number { + return (pathData.match(/[LC]/g) ?? []).length; +} + +const CELLS: readonly CellRecord[] = [ + { + id: 'a', + type: 'element', + position: { x: 40, y: 40 }, + size: { width: 120, height: 60 }, + data: { label: 'Node A' }, + } as CellRecord, + { + id: 'b', + type: 'element', + position: { x: 300, y: 200 }, + size: { width: 120, height: 60 }, + data: { label: 'Node B' }, + } as CellRecord, + { id: 'l', type: 'link', source: { id: 'a' }, target: { id: 'b' } } as CellRecord, +]; + +const renderRect = () => ; + +describe('SSR: renders a full diagram on the server', () => { + it('renders positioned nodes, links and SVG renderElement content', () => { + const html = renderToString( + + + + ); + + expect(html).toContain('