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
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ describe('InitUpdater', () => {
addPort: Mock;
addEdgeLabel: Mock;
applyNodeSize: Mock;
applyPortsSizesAndPositions: Mock;
applyEdgeLabelSize: Mock;
applyPortChanges: Mock;
applyEdgeLabelChanges: Mock;
};
};
let mockRenderedModel: { nodes: Node[]; edges: Edge[] };
Expand Down Expand Up @@ -85,8 +85,8 @@ describe('InitUpdater', () => {
addPort: vi.fn(),
addEdgeLabel: vi.fn(),
applyNodeSize: vi.fn(),
applyPortsSizesAndPositions: vi.fn(),
applyEdgeLabelSize: vi.fn(),
applyPortChanges: vi.fn(),
applyEdgeLabelChanges: vi.fn(),
},
};

Expand Down Expand Up @@ -216,8 +216,8 @@ describe('InitUpdater', () => {

expect(initUpdater.isInitialized).toBe(false);

initUpdater.applyPortsSizesAndPositions('node1', [
{ id: 'port1', size: { width: 10, height: 10 }, position: { x: 5, y: 0 } },
initUpdater.applyPortChanges('node1', [
{ portId: 'port1', portChanges: { size: { width: 10, height: 10 }, position: { x: 5, y: 0 } } },
]);

await Promise.resolve();
Expand Down Expand Up @@ -252,7 +252,9 @@ describe('InitUpdater', () => {

expect(initUpdater.isInitialized).toBe(false);

initUpdater.applyEdgeLabelSize('edge1', 'label1', { width: 50, height: 20 });
initUpdater.applyEdgeLabelChanges('edge1', [
{ labelId: 'label1', labelChanges: { size: { width: 50, height: 20 } } },
]);

await Promise.resolve();

Expand Down Expand Up @@ -385,7 +387,7 @@ describe('InitUpdater', () => {
});
});

describe('applyPortsSizesAndPositions', () => {
describe('applyPortChanges', () => {
it('should record port measurements', async () => {
const node = {
...createMockNode('node1'),
Expand Down Expand Up @@ -414,8 +416,8 @@ describe('InitUpdater', () => {
vi.advanceTimersByTime(STABILITY_DELAY);
await vi.runAllTimersAsync();

initUpdater.applyPortsSizesAndPositions('node1', [
{ id: 'port1', size: { width: 10, height: 10 }, position: { x: 5, y: 0 } },
initUpdater.applyPortChanges('node1', [
{ portId: 'port1', portChanges: { size: { width: 10, height: 10 }, position: { x: 5, y: 0 } } },
]);

await Promise.resolve();
Expand Down Expand Up @@ -451,8 +453,8 @@ describe('InitUpdater', () => {
vi.advanceTimersByTime(STABILITY_DELAY);
await Promise.resolve();

initUpdater.applyPortsSizesAndPositions('node1', [
{ id: 'port1', size: undefined as unknown as Size, position: { x: 5, y: 0 } },
initUpdater.applyPortChanges('node1', [
{ portId: 'port1', portChanges: { size: undefined as unknown as Size, position: { x: 5, y: 0 } } },
]);

await Promise.resolve();
Expand Down Expand Up @@ -509,7 +511,69 @@ describe('InitUpdater', () => {
});
});

describe('applyEdgeLabelSize', () => {
describe('applyPortChanges with non-geometric properties', () => {
it('should not track side-only changes as measurements', async () => {
const node = {
...createMockNode('node1'),
size: { width: 100, height: 100 },
measuredPorts: [
{
id: 'port1',
type: 'source' as const,
nodeId: 'node1',
side: 'top' as const,
size: undefined,
position: undefined,
},
],
};
mockRenderedModel = { nodes: [node], edges: [] };
mockFlowCore.getState.mockReturnValue({
nodes: [node],
edges: [],
metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } },
});
initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore);

initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges);

vi.advanceTimersByTime(STABILITY_DELAY);
await Promise.resolve();

// Side-only change should not complete init (port still needs size/position measurement)
initUpdater.applyPortChanges('node1', [{ portId: 'port1', portChanges: { side: 'bottom' } }]);

await Promise.resolve();

expect(initUpdater.isInitialized).toBe(false);
});

it('should queue side changes during finish for replay on InternalUpdater', async () => {
mockRenderedModel = { nodes: [], edges: [] };
mockFlowCore.getState.mockReturnValue({
nodes: [],
edges: [],
metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } },
});
initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore);

const onComplete = vi.fn(() => {
initUpdater.applyPortChanges('node1', [{ portId: 'port1', portChanges: { side: 'left' } }]);
});

initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges, onComplete);

vi.advanceTimersByTime(STABILITY_DELAY);
await vi.runAllTimersAsync();

expect(initUpdater.isInitialized).toBe(true);
expect(mockFlowCore.internalUpdater.applyPortChanges).toHaveBeenCalledWith('node1', [
{ portId: 'port1', portChanges: { side: 'left' } },
]);
});
});

describe('applyEdgeLabelChanges', () => {
beforeEach(() => {
const edge = createMockEdge('edge1', true);
mockRenderedModel = { nodes: [], edges: [edge] };
Expand All @@ -527,12 +591,69 @@ describe('InitUpdater', () => {
vi.advanceTimersByTime(STABILITY_DELAY);
await vi.runAllTimersAsync();

initUpdater.applyEdgeLabelSize('edge1', 'label1', { width: 50, height: 20 });
initUpdater.applyEdgeLabelChanges('edge1', [
{ labelId: 'label1', labelChanges: { size: { width: 50, height: 20 } } },
]);

await Promise.resolve();

expect(initUpdater.isInitialized).toBe(true);
});

it('should not track positionOnEdge-only changes as measurements', async () => {
// Override beforeEach — edge with label that has NO size (needs measurement)
const edge: Edge = {
id: 'edge1',
source: 'node1',
target: 'node2',
type: 'default',
data: {},
measuredLabels: [{ id: 'label1', positionOnEdge: 0.5, size: undefined }],
};
mockRenderedModel = { nodes: [], edges: [edge] };
mockFlowCore.getState.mockReturnValue({
nodes: [],
edges: [edge],
metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } },
});
initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore);

initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges);

vi.advanceTimersByTime(STABILITY_DELAY);
await Promise.resolve();

// positionOnEdge-only change should not complete init (label still needs size measurement)
initUpdater.applyEdgeLabelChanges('edge1', [{ labelId: 'label1', labelChanges: { positionOnEdge: 0.75 } }]);

await Promise.resolve();

expect(initUpdater.isInitialized).toBe(false);
});

it('should queue positionOnEdge changes during finish for replay on InternalUpdater', async () => {
mockRenderedModel = { nodes: [], edges: [] };
mockFlowCore.getState.mockReturnValue({
nodes: [],
edges: [],
metadata: { viewport: { position: { x: 0, y: 0 }, zoom: 1 } },
});
initUpdater = new InitUpdater(mockFlowCore as unknown as FlowCore);

const onComplete = vi.fn(() => {
initUpdater.applyEdgeLabelChanges('edge1', [{ labelId: 'label1', labelChanges: { positionOnEdge: 0.75 } }]);
});

initUpdater.start(mockRenderedModel.nodes, mockRenderedModel.edges, onComplete);

vi.advanceTimersByTime(STABILITY_DELAY);
await vi.runAllTimersAsync();

expect(initUpdater.isInitialized).toBe(true);
expect(mockFlowCore.internalUpdater.applyEdgeLabelChanges).toHaveBeenCalledWith('edge1', [
{ labelId: 'label1', labelChanges: { positionOnEdge: 0.75 } },
]);
});
});

describe('late arrival queueing', () => {
Expand Down Expand Up @@ -671,9 +792,9 @@ describe('InitUpdater', () => {
await vi.runAllTimersAsync();

initUpdater.applyNodeSize('node1', { width: 100, height: 100 });
initUpdater.applyPortsSizesAndPositions('node1', [
{ id: 'port1', size: { width: 10, height: 10 }, position: { x: 5, y: 0 } },
{ id: 'port2', size: { width: 15, height: 15 }, position: { x: 10, y: 0 } },
initUpdater.applyPortChanges('node1', [
{ portId: 'port1', portChanges: { size: { width: 10, height: 10 }, position: { x: 5, y: 0 } } },
{ portId: 'port2', portChanges: { size: { width: 15, height: 15 }, position: { x: 10, y: 0 } } },
]);

expect(mockFlowCore.setState).toHaveBeenCalledTimes(1);
Expand All @@ -699,8 +820,12 @@ describe('InitUpdater', () => {
vi.advanceTimersByTime(STABILITY_DELAY);
await vi.runAllTimersAsync();

initUpdater.applyEdgeLabelSize('edge1', 'label1', { width: 50, height: 20 });
initUpdater.applyEdgeLabelSize('edge1', 'label2', { width: 60, height: 25 });
initUpdater.applyEdgeLabelChanges('edge1', [
{ labelId: 'label1', labelChanges: { size: { width: 50, height: 20 } } },
]);
initUpdater.applyEdgeLabelChanges('edge1', [
{ labelId: 'label2', labelChanges: { size: { width: 60, height: 25 } } },
]);

expect(mockFlowCore.setState).toHaveBeenCalledTimes(1);
const stateUpdate = mockFlowCore.setState.mock.calls[0][0];
Expand Down Expand Up @@ -780,13 +905,15 @@ describe('InitUpdater', () => {
// Apply measurements
initUpdater.applyNodeSize('node1', { width: 100, height: 100 });
initUpdater.applyNodeSize('node2', { width: 150, height: 150 });
initUpdater.applyPortsSizesAndPositions('node1', [
{ id: 'port1', size: { width: 10, height: 10 }, position: { x: 5, y: 0 } },
initUpdater.applyPortChanges('node1', [
{ portId: 'port1', portChanges: { size: { width: 10, height: 10 }, position: { x: 5, y: 0 } } },
]);
initUpdater.applyPortChanges('node2', [
{ portId: 'port1', portChanges: { size: { width: 10, height: 10 }, position: { x: 5, y: 0 } } },
]);
initUpdater.applyPortsSizesAndPositions('node2', [
{ id: 'port1', size: { width: 10, height: 10 }, position: { x: 5, y: 0 } },
initUpdater.applyEdgeLabelChanges('edge1', [
{ labelId: 'label1', labelChanges: { size: { width: 50, height: 20 } } },
]);
initUpdater.applyEdgeLabelSize('edge1', 'label1', { width: 50, height: 20 });

await vi.runAllTimersAsync();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { FlowCore } from '../../flow-core';
import { Edge, EdgeLabel, Node, Port, Size } from '../../types';
import type { LabelUpdate } from '../../label-batch-processor/label-batch-processor';
import type { PortUpdate } from '../../port-batch-processor/port-batch-processor';
import { Edge, EdgeLabel, Node, Port } from '../../types';
import { Updater } from '../updater.interface';
import { InitState } from './init-state';
import { LateArrivalQueue } from './late-arrival-queue';
Expand Down Expand Up @@ -38,7 +40,7 @@ Documentation: https://www.ngdiagram.dev/docs/guides/model-initialization/
*
* Strategy:
* 1. Wait for entity creation to stabilize (addPort, addEdgeLabel) using StabilityDetectors
* 2. Immediately collect measurements (applyNodeSize, applyPortsSizesAndPositions, applyEdgeLabelSize)
* 2. Immediately collect measurements (applyNodeSize, applyPortChanges, applyEdgeLabelChanges)
* 3. Finish when: entities stabilized AND all rendered entities have measurements
* 4. Apply everything in one setState
* 5. Queue late arrivals to prevent data loss during finish transition
Expand Down Expand Up @@ -148,21 +150,23 @@ export class InitUpdater implements Updater {
}

/**
* Records port measurements (sizes and positions).
* Queues if finishing, otherwise records all measurements and attempts to finish.
* Applies port changes (size, position, side, type, etc.).
* During initialization, only size/position measurements are tracked for init completion.
* Non-geometric changes (side, type) are ignored since ports are being created with initial values.
*
* @param nodeId - The node ID the ports belong to
* @param ports - Array of port measurements
* @param portUpdates - Array of port updates
*/
applyPortsSizesAndPositions(nodeId: string, ports: NonNullable<Pick<Port, 'id' | 'size' | 'position'>>[]): void {
applyPortChanges(nodeId: string, portUpdates: PortUpdate[]): void {
if (this.lateArrivalQueue.isFinishing) {
this.lateArrivalQueue.enqueue({ method: 'applyPortsSizesAndPositions', args: [nodeId, ports] });
this.lateArrivalQueue.enqueue({ method: 'applyPortChanges', args: [nodeId, portUpdates] });
return;
}

for (const { id, size, position } of ports) {
if (!size || !position) continue;
this.initState.trackPortMeasurement(nodeId, id, size, position);
for (const { portId, portChanges } of portUpdates) {
if (portChanges.size && portChanges.position) {
this.initState.trackPortMeasurement(nodeId, portId, portChanges.size, portChanges.position);
}
}

this.tryFinish();
Expand All @@ -186,20 +190,25 @@ export class InitUpdater implements Updater {
}

/**
* Records an edge label size measurement.
* Queues if finishing, otherwise records measurement and attempts to finish.
* Applies edge label changes (size, positionOnEdge, etc.).
* During initialization, only size measurements are tracked for init completion.
* Non-size changes (positionOnEdge) are ignored since labels are being created with initial values.
*
* @param edgeId - The edge ID the label belongs to
* @param labelId - The label ID
* @param size - The measured size
* @param edgeId - The edge ID the labels belong to
* @param labelUpdates - Array of label updates
*/
applyEdgeLabelSize(edgeId: string, labelId: string, size: Size): void {
applyEdgeLabelChanges(edgeId: string, labelUpdates: LabelUpdate[]): void {
if (this.lateArrivalQueue.isFinishing) {
this.lateArrivalQueue.enqueue({ method: 'applyEdgeLabelSize', args: [edgeId, labelId, size] });
this.lateArrivalQueue.enqueue({ method: 'applyEdgeLabelChanges', args: [edgeId, labelUpdates] });
return;
}

this.initState.trackLabelMeasurement(edgeId, labelId, size);
for (const { labelId, labelChanges } of labelUpdates) {
if (labelChanges.size) {
this.initState.trackLabelMeasurement(edgeId, labelId, labelChanges.size);
}
}

this.tryFinish();
}

Expand Down
Loading
Loading