Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,17 @@
},
"workspaces": [
"./packages/*",
"./packages/joint-svg-shim/examples/*",
"./examples/*"
],
"volta": {
"node": "22.14.0",
"npm": "11.2.0",
"yarn": "4.7.0"
},
"resolutions": {
"react": "19.2.5",
"react-dom": "19.2.5"
},
"packageManager": "yarn@4.7.0"
}
14 changes: 13 additions & 1 deletion packages/joint-react/.storybook/decorators/with-strict-mode.tsx
Original file line number Diff line number Diff line change
@@ -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 <Story />;
return (
<StrictMode>
<Story />
</StrictMode>
);
}
6 changes: 6 additions & 0 deletions packages/joint-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).

5 changes: 5 additions & 0 deletions packages/joint-react/__mocks__/jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
25 changes: 25 additions & 0 deletions packages/joint-react/examples/build.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
19 changes: 19 additions & 0 deletions packages/joint-react/examples/bun-environment.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions packages/joint-react/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export default {
],
moduleNameMapper: {
'^.+\\.css$': '<rootDir>/__mocks__/style-mock.ts', // Mock CSS files
'^@joint/svg-shim/install$': '<rootDir>/../joint-svg-shim/src/install.ts',
'^@joint/svg-shim/flag$': '<rootDir>/../joint-svg-shim/src/dom-shim-flag.ts',
'^@joint/svg-shim$': '<rootDir>/../joint-svg-shim/src/index.ts',
'^@joint/react$': '<rootDir>/src/index.ts',
'^src/(.*)$': '<rootDir>/src/$1',
'^storybook-config/(.*)$': '<rootDir>/.storybook/$1',
Expand Down
28 changes: 23 additions & 5 deletions packages/joint-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand All @@ -62,7 +72,6 @@
"Samuel <samuel@jointjs.com> (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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion packages/joint-react/rollup.config.ts
Original file line number Diff line number Diff line change
@@ -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',
],
});
84 changes: 84 additions & 0 deletions packages/joint-react/src/__tests__/ssr-client-handoff.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => <rect />;
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 (
<GraphProvider initialCells={CELLS}>
<output data-testid="clicks">{clicks}</output>
<Paper
ref={setPaper}
style={{ width: 100, height: 100 }}
renderElement={renderRect}
onElementPointerClick={() => setClicks((value) => value + 1)}
/>
</GraphProvider>
);
}

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, <App />);
});

// 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');
});
});
93 changes: 93 additions & 0 deletions packages/joint-react/src/__tests__/ssr-paper-handoff.test.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Paper>` 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 = () => <rect width={60} height={40} fill="#444" />;
const fakeEvent = {} as dia.Event;

function App({ onClick }: Readonly<{ onClick?: () => void }>) {
return (
<GraphProvider initialCells={CELLS}>
<Paper style={{ width: 100, height: 100 }} renderElement={renderRect} onElementPointerClick={onClick} />
</GraphProvider>
);
}

it('server-renders the full diagram (nodes + renderElement) as HTML', () => {
const globalScope = globalThis as Record<string, unknown>;
globalScope[DOM_SHIM_FLAG] = true;
let html = '';
try {
html = renderToString(<App />);
} 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('<rect'); // renderElement content
});

it('hydrates to a live interactive paper on the client (events drive state)', async () => {
let paper: dia.Paper | null = null;
const setPaper = (instance: dia.Paper | null) => {
if (instance) paper = instance;
};

function ClientApp() {
const [clicks, setClicks] = useState(0);
return (
<GraphProvider initialCells={CELLS}>
<output data-testid="clicks">{clicks}</output>
<Paper
ref={setPaper}
style={{ width: 100, height: 100 }}
renderElement={renderRect}
onElementPointerClick={() => setClicks((value) => value + 1)}
/>
</GraphProvider>
);
}

const container = document.createElement('div');
document.body.append(container);
await act(async () => {
hydrateRoot(container, <ClientApp />);
});

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');
});
});
Loading