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
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
12 changes: 7 additions & 5 deletions packages/joint-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -62,7 +65,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 Down Expand Up @@ -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",
Expand Down
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');
});
});
86 changes: 86 additions & 0 deletions packages/joint-react/src/__tests__/ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ul>
{cells.map((cell) => (
<li key={cell.id}>{String(cell.id)}</li>
))}
</ul>
);
}

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(
<GraphProvider initialCells={CELLS}>
<div>server-child</div>
</GraphProvider>
);
}).not.toThrow();
expect(html).toContain('server-child');
});

it('exposes graph data to hooks during SSR (useCells works on the server)', () => {
const html = renderToString(
<GraphProvider initialCells={CELLS}>
<CellIds />
</GraphProvider>
);
expect(html).toContain('<li>a</li>');
expect(html).toContain('<li>b</li>');
});

it('Paper degrades gracefully on the server (renders host element, no crash)', () => {
let html = '';
expect(() => {
html = renderToString(
<GraphProvider initialCells={CELLS}>
{/* eslint-disable-next-line react-perf/jsx-no-new-function-as-prop */}
<Paper renderElement={() => <rect />} />
</GraphProvider>
);
}).not.toThrow();
// The host container renders; the SVG/portal content is client-only.
expect(html).toContain('<div');
});
});
41 changes: 30 additions & 11 deletions packages/joint-react/src/components/graph/graph-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import type { dia } from '@joint/core';
import React, { useLayoutEffect } from 'react';
import React from 'react';
import { useImperativeApi } from '../../hooks/use-imperative-api';
import { useIsomorphicLayoutEffect } from '../../hooks/use-isomorphic-layout-effect';
import { GraphStoreContext } from '../../context';
import { GraphStore } from '../../store';

/**
* True when running without a DOM (server-side rendering). Constant for the
* lifetime of the environment, so it is safe to branch rendering on it.
*/
const IS_SERVER = typeof document === 'undefined';
import type { AutoSizeOrigin } from '../../store/graph-store';
import type { OnIncrementalCellsChange } from '../../store/graph-projection';
import type { ElementJSONInit, LinkJSONInit } from '../../types/cell.types';
Expand Down Expand Up @@ -103,20 +110,25 @@ function GraphBase(props: GraphProviderBaseInternalProps) {

const isControlled = !!cells;

const buildStore = (): GraphStore<ElementJSONInit, LinkJSONInit> =>
store ??
new GraphStore<ElementJSONInit, LinkJSONInit>({
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<GraphStore<ElementJSONInit, LinkJSONInit>, dia.Graph>(
{
instanceSelector: (instance) => instance.graph,
forwardedRef,
onLoad() {
const graphStore =
store ??
new GraphStore<ElementJSONInit, LinkJSONInit>({
graph,
cellNamespace,
cellModel,
initialCells: cells ?? initialCells ?? [],
autoSizeOrigin,
});
const graphStore = buildStore();
return {
cleanup() {
if (store) return;
Expand All @@ -129,7 +141,7 @@ function GraphBase(props: GraphProviderBaseInternalProps) {
[]
);

useLayoutEffect(() => {
useIsomorphicLayoutEffect(() => {
if (!isReady) return;
ref.current.setOnIncrementalCellsChange((changeSet) => {
onIncrementalCellsChange?.(changeSet);
Expand All @@ -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; `<Paper>` degrades to its host element.
// On the client this branch is never reached once the layout effect runs.
if (IS_SERVER) {
return <GraphStoreContext.Provider value={buildStore()}>{children}</GraphStoreContext.Provider>;
}
return null;
}

Expand Down
Loading