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
40 changes: 40 additions & 0 deletions src/graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,43 @@ describe("Graph colors runtime merge behavior", () => {
expect(graph.graphColors.anchor?.background).toBe("#222222");
});
});

describe("setEntities + waitUsableRectUpdate (pendingEntitiesUpdate fix)", () => {
function makeBlock(id: string, x = 0, y = 0): TBlock {
return { id, is: "Block", x, y, width: 100, height: 50, selected: false, name: id, anchors: [] };
}

it("resolves waitUsableRectUpdate after setEntities with same block positions", (done) => {
const node = document.createElement("div");
const block = makeBlock("b1", 10, 20);
const graph = new Graph({ blocks: [block], connections: [] }, node);
graph.start();

// Wait for initial hitboxes to settle
graph.hitTest.waitUsableRectUpdate(() => {
// Call setEntities with the exact same block (same position)
graph.setEntities({ blocks: [block], connections: [] });

// Must resolve after setEntities even though usableRect doesn't change
graph.hitTest.waitUsableRectUpdate((rect) => {
expect(rect.width).toBeGreaterThan(0);
done();
});
});
}, 5000);

it("resolves waitUsableRectUpdate after setEntities with new blocks", (done) => {
const node = document.createElement("div");
const graph = new Graph({ blocks: [makeBlock("b1")], connections: [] }, node);
graph.start();

graph.hitTest.waitUsableRectUpdate(() => {
graph.setEntities({ blocks: [makeBlock("b2", 200, 200)], connections: [] });

graph.hitTest.waitUsableRectUpdate((rect) => {
expect(rect.x).toBeGreaterThanOrEqual(200);
done();
});
});
}, 5000);
});
9 changes: 4 additions & 5 deletions src/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,11 +304,10 @@ export class Graph {
blocks?: TBlock[];
connections?: TConnection[];
}>) {
// Reset usableRect so that waitUsableRectUpdate (used by zoomToViewPort)
// treats hitTest as unstable and waits for new block components to register
// their hitboxes. Without this, a stale usableRect can make hitTest appear
// stable, causing the zoom callback to fire immediately with wrong data.
this.hitTest.resetUsableRect();
// Mark hitTest as pending so waitUsableRectUpdate (used by zoomToViewPort)
// waits for block components to settle. Unlike resetUsableRect, this does not
// clear usableRectTracker, so blocks that haven't moved don't need to re-register.
this.hitTest.markPendingUpdate();
batch(() => {
this.rootStore.blocksList.setBlocks(blocks || []);
this.rootStore.connectionsList.setConnections(connections || []);
Expand Down
89 changes: 89 additions & 0 deletions src/services/HitTest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Graph } from "../graph";

import { HitBox, HitTest } from "./HitTest";

function makeHitTest(hasBlocks = false): HitTest {
const mockGraph = {
rootStore: {
blocksList: { $blocks: { value: hasBlocks ? [{}] : [] } },
connectionsList: { $connections: { value: [] } },
},
} as unknown as Graph;
return new HitTest(mockGraph);
}

function seedUsableRect(ht: HitTest): void {
const fakeHitBox = {
affectsUsableRect: true,
destroyed: false,
minX: 0,
minY: 0,
maxX: 100,
maxY: 100,
x: 0,
y: 0,
} as unknown as HitBox;
(ht as unknown as { usableRectTracker: { add(h: HitBox): void } }).usableRectTracker.add(fakeHitBox);
(ht as unknown as { updateUsableRect(): void }).updateUsableRect();
}

/**
* Trigger processQueue by queueing a fake hitbox update and flushing.
* This mirrors real production code: hitbox updates always precede processQueue.
*/
function triggerProcessQueue(ht: HitTest): void {
const fakeHitBox = {
affectsUsableRect: true,
destroyed: false,
minX: 0,
minY: 0,
maxX: 100,
maxY: 100,
x: 0,
y: 0,
updateRect(_bbox: unknown): void {
// no-op for test fake
},
} as unknown as HitBox;
ht.update(fakeHitBox, { minX: 0, minY: 0, maxX: 100, maxY: 100, x: 0, y: 0 });
(ht as unknown as { processQueue: { flush(): void } }).processQueue.flush();
}

describe("HitTest.markPendingUpdate", () => {
it("makes isUnstable true immediately after call", () => {
const ht = makeHitTest(true);
// Seed non-zero usableRect so zero-rect heuristic doesn't interfere
seedUsableRect(ht);
expect(ht.isUnstable).toBe(false);
ht.markPendingUpdate();
expect(ht.isUnstable).toBe(true);
});

it("isUnstable becomes false after processQueue flushes with a hitbox update", () => {
const ht = makeHitTest(true);
seedUsableRect(ht);
ht.markPendingUpdate();
expect(ht.isUnstable).toBe(true);
// processQueue fires naturally when hitbox updates arrive; simulate that here
triggerProcessQueue(ht);
expect(ht.isUnstable).toBe(false);
});

it("waitUsableRectUpdate resolves when flag clears, even if usableRect did not change", () => {
const ht = makeHitTest(true);
seedUsableRect(ht);

ht.markPendingUpdate();
expect(ht.isUnstable).toBe(true);

let called = false;
ht.waitUsableRectUpdate(() => {
called = true;
});
expect(called).toBe(false); // still waiting

// processQueue fires when hitbox updates arrive (simulated here)
triggerProcessQueue(ht);
expect(called).toBe(true); // resolved after flag cleared
});
});
46 changes: 38 additions & 8 deletions src/services/HitTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export class HitTest extends Emitter {

public readonly $usableRect = signal<TRect>({ x: 0, y: 0, width: 0, height: 0 });

// Explicit pending flag set by setEntities; cleared when processQueue completes.
// Avoids the "zero usableRect = unstable" heuristic for entity replacement.
private readonly $pendingEntitiesUpdate = signal(false);

// Single queue replaces all complex state tracking
protected queue = new Map<HitBox, HitBoxData | null>();

Expand Down Expand Up @@ -90,10 +94,10 @@ export class HitTest extends Emitter {

// If graph has no elements, it's stable even with zero usableRect
if (hasZeroUsableRect && !this.hasGraphElements()) {
return hasProcessingQueue;
return hasProcessingQueue || this.$pendingEntitiesUpdate.value;
}

return hasProcessingQueue || hasZeroUsableRect;
return hasProcessingQueue || hasZeroUsableRect || this.$pendingEntitiesUpdate.value;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -148,6 +152,7 @@ export class HitTest extends Emitter {
this.queue.clear();
this.updateUsableRect();
this.emit("update", this);
this.$pendingEntitiesUpdate.value = false;
},
{
priority: ESchedulerPriority.LOWEST,
Expand Down Expand Up @@ -176,6 +181,7 @@ export class HitTest extends Emitter {
this.interactiveTree.clear();
this.usableRectTracker.clear();
this.updateUsableRect();
this.$pendingEntitiesUpdate.value = false;
}

/**
Expand All @@ -188,6 +194,20 @@ export class HitTest extends Emitter {
this.updateUsableRect();
}

/**
* Mark hitTest as pending an entity update (called by setEntities).
* Makes isUnstable = true until processQueue completes.
* Does NOT clear usableRectTracker — existing data stays valid.
* Does NOT eagerly schedule processQueue — it fires naturally when hitbox
* updates arrive (new components register) or when updateBlock forces a hitbox
* refresh on existing components. Eager scheduling caused processQueue to run
* in the same rAF frame as markPendingUpdate (before MEDIUM/component renders),
* clearing $pendingEntitiesUpdate before new block hitboxes were queued.
*/
public markPendingUpdate(): void {
this.$pendingEntitiesUpdate.value = true;
}

/**
* Add new HitBox item
* @param item HitBox item to add
Expand All @@ -213,15 +233,25 @@ export class HitTest extends Emitter {
}

if (this.isUnstable) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider introducing a single stability tick signal and a local pending flag variable to simplify waitUsableRectUpdate subscriptions and the isUnstable logic.

You can keep the new behavior but move the coordination logic out of waitUsableRectUpdate, so it only has a single subscription again, and simplify the isUnstable getter a bit.

1. Centralize the “stability-related changes” into a single signal

Instead of dual subscriptions + manual cleanup in waitUsableRectUpdate, introduce a simple “tick” signal that bumps whenever either $usableRect or $pendingEntitiesUpdate changes. Then waitUsableRectUpdate can subscribe once and keep its previous structure.

// New field
private readonly $stabilityTick = signal(0);

constructor(protected graph: Graph) {
  super();

  // Any change that could affect stability increments the tick
  this.$usableRect.subscribe(() => {
    this.$stabilityTick.value++;
  });
  this.$pendingEntitiesUpdate.subscribe(() => {
    this.$stabilityTick.value++;
  });
}

Then waitUsableRectUpdate becomes:

public waitUsableRectUpdate(callback: (rect: TRect) => void): () => void {
  // For empty graphs, immediately call callback with current usableRect
  if (!this.hasGraphElements()) {
    callback(this.$usableRect.value);
    return noop;
  }

  if (this.isUnstable) {
    const unsubscribe = this.$stabilityTick.subscribe(() => {
      if (!this.isUnstable) {
        unsubscribe();
        callback(this.$usableRect.value);
      }
    });
    return unsubscribe;
  }

  callback(this.$usableRect.value);
  return noop;
}

Behavior stays the same: we still wake up when either rect changes or the pending flag flips, but the complexity is now in one small place (the constructor wiring), and waitUsableRectUpdate regains a single, easy-to-read subscription.

2. Reduce duplication in isUnstable

You can make the logic a bit easier to parse by hoisting the pending flag into a local:

public get isUnstable() {
  const hasProcessingQueue = this.processQueue.isScheduled() || this.queue.size > 0;
  const hasZeroUsableRect =
    this.$usableRect.value.height === 0 &&
    this.$usableRect.value.width === 0 &&
    this.$usableRect.value.x === 0 &&
    this.$usableRect.value.y === 0;
  const hasPendingEntitiesUpdate = this.$pendingEntitiesUpdate.value;

  // If graph has no elements, it's stable even with zero usableRect
  if (hasZeroUsableRect && !this.hasGraphElements()) {
    return hasProcessingQueue || hasPendingEntitiesUpdate;
  }

  return hasProcessingQueue || hasZeroUsableRect || hasPendingEntitiesUpdate;
}

Same semantics, but the duplicated this.$pendingEntitiesUpdate.value access and condition are clearer.

const removeListener = this.$usableRect.subscribe(() => {
let cleaned = false;
let unsubRect: () => void = noop;
let unsubPending: () => void = noop;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
unsubRect();
unsubPending();
};
const check = () => {
if (!this.isUnstable) {
removeListener();
cleanup();
// eslint-disable-next-line callback-return
callback(this.$usableRect.value);
return;
}
return;
});
return removeListener;
};
unsubRect = this.$usableRect.subscribe(check);
unsubPending = this.$pendingEntitiesUpdate.subscribe(check);
return cleanup;
}
callback(this.$usableRect.value);
return noop;
Expand Down
17 changes: 14 additions & 3 deletions src/utils/utils/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,20 @@ export const debounce = <T extends (...args: Parameters<T>) => void>(
};

const flush = () => {
if (isScheduled && latestArgs) {
fn(...latestArgs);
cancel();
if (isScheduled) {
const currentRemoveScheduler = removeScheduler;
isScheduled = false;
frameCounter = 0;
startTime = 0;
removeScheduler = null;
const args = latestArgs;
latestArgs = undefined;
// Remove the old scheduler handle BEFORE calling fn() so that any
// re-scheduling triggered inside fn() is not accidentally canceled.
if (currentRemoveScheduler) {
currentRemoveScheduler();
}
fn(...((args ?? []) as Parameters<T>));
}
};

Expand Down
Loading