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 (
+
+
+ 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 (
+
+
+ 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('