Skip to content
Closed
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 .changeset/react-cells-inline-selector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": patch
---

useCells - fix an inline selector that captures a changed prop returning a stale value until the next graph change
2 changes: 1 addition & 1 deletion .changeset/react-elements-measured-app-resize.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@joint/react": patch
---

useOnElementsMeasured - fix to fire exactly once per settled change, when no element is still waiting to be measured
useOnElementsMeasured - fix to fire exactly once per settled change (an addition, a removal or a re-measurement), when no element is still waiting to be measured
5 changes: 5 additions & 0 deletions .changeset/react-store-callbacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": patch
---

<GraphProvider /> - fix a store callback that throws or changes the graph breaking later updates
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { memo, useLayoutEffect, useRef } from 'react';
import { useImperativeApi } from '../../hooks/use-imperative-api';
import { GraphStoreContext } from '../../context';
import { GraphStore } from '../../store';
import type { AutoSizeOrigin } from '../../store/graph-store';
import type { AutoSizeOrigin } from '../../store/measurement';
import type { OnIncrementalCellsChange } from '../../store/graph-projection';
import type { ElementJSONInit, LinkJSONInit, CellInput } from '../../types/cell.types';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { CellIdContext, GraphStoreContext, PaperStoreContext } from '../../../..
import { ELEMENT_MODEL_TYPE } from '../../../../mvc/element-model';
import type { CellRecord, CellId } from '../../../../types/cell.types';

const RenderEmpty: ComponentType<Record<string, unknown>> = () => <span data-testid="render-empty" />;
const RenderEmpty: ComponentType<Record<string, unknown>> = () => (
<span data-testid="render-empty" />
);

const CELLS: readonly CellRecord[] = [
{
Expand All @@ -27,7 +29,11 @@ const CELLS: readonly CellRecord[] = [
* can re-mount SVG / HTML element items with `portalElement={null}` for the
* defensive guard branches.
*/
function StoreCapture({ onCapture }: { readonly onCapture: (graph: unknown, paper: unknown) => void }) {
function StoreCapture({
onCapture,
}: {
readonly onCapture: (graph: unknown, paper: unknown) => void;
}) {
const graphStore = useContext(GraphStoreContext);
const paperStore = useContext(PaperStoreContext);
if (graphStore && paperStore) onCapture(graphStore, paperStore);
Expand Down Expand Up @@ -63,11 +69,7 @@ describe('paper-element-item exports', () => {
value={capturedPaper as React.ContextType<typeof PaperStoreContext>}
>
<CellIdContext.Provider value={'one' as CellId}>
<SVGElementItem
renderElement={RenderEmpty}
portalElement={null}
areElementsMeasured
/>
<SVGElementItem renderElement={RenderEmpty} portalElement={null} />
</CellIdContext.Provider>
</PaperStoreContext.Provider>
</GraphStoreContext.Provider>
Expand Down Expand Up @@ -101,11 +103,7 @@ describe('paper-element-item exports', () => {
value={capturedPaper as React.ContextType<typeof PaperStoreContext>}
>
<CellIdContext.Provider value={'one' as CellId}>
<HTMLElementItem
renderElement={RenderEmpty}
portalElement={null}
areElementsMeasured
/>
<HTMLElementItem renderElement={RenderEmpty} portalElement={null} />
</CellIdContext.Provider>
</PaperStoreContext.Provider>
</GraphStoreContext.Provider>
Expand Down Expand Up @@ -143,18 +141,16 @@ describe('paper-element-item exports', () => {
value={capturedPaper as React.ContextType<typeof PaperStoreContext>}
>
<CellIdContext.Provider value={'missing-cell-id' as CellId}>
<HTMLElementItem
renderElement={RenderEmpty}
portalElement={portalTarget}
areElementsMeasured
/>
<HTMLElementItem renderElement={RenderEmpty} portalElement={portalTarget} />
</CellIdContext.Provider>
</PaperStoreContext.Provider>
</GraphStoreContext.Provider>
);

// Placeholder wrapper should still be created with id and zero geometry.
const wrapper = portalTarget.querySelector('div[model-id="missing-cell-id"]') as HTMLDivElement | null;
const wrapper = portalTarget.querySelector(
'div[model-id="missing-cell-id"]'
) as HTMLDivElement | null;
expect(wrapper).toBeTruthy();
expect(wrapper?.style.width).toBe('0px');
expect(wrapper?.style.height).toBe('0px');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
useLayoutEffect,
useMemo,
type ComponentType,
type CSSProperties,
} from 'react';
import { useLayoutEffect, useMemo, type ComponentType, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import typedMemo from '../../../utils/typed-react';
import { useGraphStore } from '../../../hooks/use-graph-store';
Expand All @@ -27,20 +22,18 @@ export interface ElementItemProps {
readonly renderElement: ComponentType<Record<string, unknown>>;
/** The DOM element to portal into. */
readonly portalElement: SVGElement | HTMLElement | null;
/** Whether all auto-sized elements have been measured. */
readonly areElementsMeasured: boolean;
}

/**
* SVG element portal component. Subscribes only to the element's `data`
* slice, position and size are handled by JointJS's view transform and
* never cause a React re-render here. Clears cached views after
* measurement to force re-render with correct dimensions.
* never cause a React re-render here. Clears the cached view once its content
* is committed, so links resolve against the rendered magnets.
* @param props - render/portal props
* @internal
*/
function SVGElementItemComponent(props: ElementItemProps) {
const { renderElement: RenderElement, portalElement, areElementsMeasured } = props;
const { renderElement: RenderElement, portalElement } = props;
const id = useCellId();
// Subscribe to just this element's `data` slice (missing-tolerant — the portal
// can mount before the record lands in the store, and briefly after removal).
Expand All @@ -54,13 +47,13 @@ function SVGElementItemComponent(props: ElementItemProps) {
// inside `renderElement` has already registered with the size observer
// (in HTML overlay mode this item follows `HTMLElementItem`, which renders
// the user content, in sibling order). O(1), and a no-op on re-runs.
graphStore.markElementRendered(id);
graphStore.measurement.markRendered(id);
if (!paper) return;
graphStore.clearViewForElementAndLinks({
cellId: id,
paper,
});
}, [id, graphStore, areElementsMeasured, paper]);
}, [id, graphStore, paper]);

if (!portalElement) {
return null;
Expand Down
75 changes: 75 additions & 0 deletions packages/joint-react/src/hooks/__tests__/use-cells.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1310,3 +1310,78 @@ describe('useCells (selector reactivity — no collection)', () => {
expect(renderedIds).toEqual(['a', 'b']);
});
});

describe('useCells — what a re-render changes', () => {
const element = (id: string | number, x: number): CellRecord =>
({
id,
type: ELEMENT_MODEL_TYPE,
position: { x, y: 0 },
size: { width: 10, height: 10 },
}) as CellRecord;
const cells: readonly CellRecord[] = [element(1, 100), element('1', 200), element('a|b', 300)];
const selectXs = (picked: readonly CellRecord[]) =>
picked.map((cell) => (cell as { readonly position?: { readonly x: number } }).position?.x);
function cellsWrapper({ children }: { readonly children: React.ReactNode }) {
return <GraphProvider initialCells={cells}>{children}</GraphProvider>;
}

// Regression: ids were joined into the key that says "same target", so the
// number 1 and the string '1' were one target and the hook kept the first.
it('tells a numeric id from the same id as a string', () => {
const { result, rerender } = renderHook(
({ ids }: { readonly ids: ReadonlyArray<string | number> }) => useCells(ids, selectXs),
{ wrapper: cellsWrapper, initialProps: { ids: [1] as ReadonlyArray<string | number> } }
);
expect(result.current).toEqual([100]);

rerender({ ids: ['1'] });

expect(result.current).toEqual([200]);
});

// Regression: the selector was read through a ref and run on a store change
// only, so an inline selector returned what the previous props selected.
it('runs an inline selector that captured a changed prop, without a graph change', () => {
const { result, rerender } = renderHook(
({ minimumX }: { readonly minimumX: number }) =>
useCells(
(all) =>
all.filter(
(cell) =>
((cell as { readonly position?: { readonly x: number } }).position?.x ?? 0) >=
minimumX
).length
),
{ wrapper: cellsWrapper, initialProps: { minimumX: 0 } }
);
expect(result.current).toBe(3);

rerender({ minimumX: 250 });

expect(result.current).toBe(1);
});
});

describe('useCells dev warnings', () => {
// Regression: the warning named `useGraphState`, which useCells runs on, not
// the hook the application called.
it('names useCells when its selector returns new objects on every run', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
const { result } = renderHook(
() => {
useCells((cells) => cells.map((cell) => ({ id: cell.id })));
return useGraphStore().graph;
},
{ wrapper }
);

await act(async () => {
(result.current.getCell('a') as dia.Element).position(5, 0);
await flush();
});

expect(warn).toHaveBeenCalledWith(expect.stringContaining('[useCells] Selector returns'));
warn.mockRestore();
});
});
Loading
Loading