Skip to content
Merged
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-elements-measured-app-resize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": patch
---

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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { useLayoutEffect } 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
Expand Up @@ -29,8 +29,6 @@ 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;
}

/**
Expand Down Expand Up @@ -89,24 +87,31 @@ function useUnresolvedElement(id: CellId): ElementRecord {
/**
* 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();
const data = useElementDataSnapshot(id);
const graphStore = useGraphStore();
const { paper } = usePaper();
useLayoutEffect(() => {
// Runs after the portal subtree's layout effects, so a `useMeasureElement`
// 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. Keyed
// on the portal target too: a same-id reset whose replacement view renders
// before React commits keeps this item mounted, with a new target.
graphStore.measurement.markRendered(id);
if (!paper) return;
graphStore.clearViewForElementAndLinks({
cellId: id,
paper,
});
}, [id, graphStore, areElementsMeasured, paper]);
}, [id, graphStore, paper, portalElement]);

if (!portalElement) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { LINK_MODEL_TYPE } from '../../mvc/link-model';
import type { CellRecord } from '../../types/cell.types';

let capturedGraph: dia.Graph | null = null;
let capturedStore: ReturnType<typeof useGraphStore> | null = null;
let capturedPaper: dia.Paper | null = null;

const initialCells: readonly CellRecord[] = [
Expand All @@ -53,8 +54,10 @@ const initialCells: readonly CellRecord[] = [

function Probe() {
const nodeRef = useRef<SVGRectElement | null>(null);
const { graph } = useGraphStore();
const store = useGraphStore();
const { graph } = store;
const { paper } = usePaper();
capturedStore = store;
capturedGraph = graph;
capturedPaper = paper;
const size = useMeasureElement(nodeRef);
Expand Down Expand Up @@ -135,7 +138,8 @@ describe('useMeasureElement', () => {
expect(() =>
render(
<GraphProvider initialCells={initialCells}>
<Paper style={{ width: 100, height: 100 }}
<Paper
style={{ width: 100, height: 100 }}
id="measure-throw-paper"
renderElement={renderNullElement}
>
Expand Down Expand Up @@ -172,7 +176,11 @@ describe('useMeasureElement', () => {
}
render(
<GraphProvider initialCells={initialCells}>
<Paper style={{ width: 100, height: 100 }} id="measure-link-paper" renderElement={renderLinkProbe} />
<Paper
style={{ width: 100, height: 100 }}
id="measure-link-paper"
renderElement={renderLinkProbe}
/>
</GraphProvider>
);
await new Promise((resolve) => setTimeout(resolve, 50));
Expand All @@ -184,7 +192,11 @@ describe('useMeasureElement', () => {
it('returns size record without registering when ref.current is null at mount', async () => {
const { container } = render(
<GraphProvider initialCells={initialCells}>
<Paper style={{ width: 100, height: 100 }} id="measure-null-paper" renderElement={renderNoNodeProbe} />
<Paper
style={{ width: 100, height: 100 }}
id="measure-null-paper"
renderElement={renderNoNodeProbe}
/>
</GraphProvider>
);
await waitFor(() => {
Expand All @@ -193,6 +205,29 @@ describe('useMeasureElement', () => {
});
});

// Regression: the paper can route the links before React commits a measured
// size, so the measured element clears its view once that size is committed.
// This used to be a sweep of every element on a global "measured" flag.
it('clears the view of a measured element when its measured size commits', async () => {
renderProbe();
await waitFor(() => expect(capturedPaper?.findViewByModel('el')).toBeDefined());
const clearView = jest.spyOn(capturedStore!, 'clearViewForElementAndLinks');
const element = capturedGraph!.getCell('el') as dia.Element;

await act(async () => {
element.position(5, 5);
await Promise.resolve();
});
expect(clearView).not.toHaveBeenCalled();

await act(async () => {
element.set('size', { width: 120, height: 60 }, { autoSize: true });
await Promise.resolve();
});
const clearedIds = clearView.mock.calls.map(([options]) => options.cellId);
expect(clearedIds).toEqual(['el']);
});

describe('with a real ResizeObserver mock', () => {
// Local ResizeObserver mock so we can deliver entries — the package-level
// setup in `__mocks__/jest-setup.ts` installs a stub that never fires.
Expand Down
Loading
Loading