Skip to content

fix: hittest pending entities update#297

Open
draedful wants to merge 4 commits into
mainfrom
fix/hittest-pending-entities-update
Open

fix: hittest pending entities update#297
draedful wants to merge 4 commits into
mainfrom
fix/hittest-pending-entities-update

Conversation

@draedful
Copy link
Copy Markdown
Collaborator

@draedful draedful commented May 12, 2026

Summary by Sourcery

Introduce an explicit pending-entities flag in hit testing to make usable-rect updates robust during entity replacement and adjust scheduling to avoid stale or hanging updates.

Bug Fixes:

  • Ensure waitUsableRectUpdate resolves correctly after setEntities, even when block positions do not change or only new blocks are introduced, by tracking pending entity updates explicitly.
  • Prevent inconsistent scheduler state during debounced callbacks by clearing scheduling metadata before invoking the debounced function.

Enhancements:

  • Add a pendingEntitiesUpdate signal and markPendingUpdate API to HitTest so instability is derived from explicit state rather than zero-rect heuristics, and wire it into isUnstable, clear, and waitUsableRectUpdate.
  • Refine Graph.setEntities to mark hit testing as pending instead of resetting usableRect, preserving existing usable-rect tracking for unchanged blocks and reducing unnecessary re-registration.

Documentation:

  • Document the design and implementation plan for replacing resetUsableRect with markPendingUpdate and the pendingEntitiesUpdate flag in HitTest.

Tests:

  • Add unit tests for HitTest.markPendingUpdate behavior, covering instability transitions and waitUsableRectUpdate resolution based on the new pending flag.
  • Extend graph integration tests to cover setEntities + waitUsableRectUpdate flows for both unchanged and new blocks, guarding against regressions in zoom-related behavior.

draedful and others added 2 commits May 7, 2026 20:02
…ntities

The old approach zeroed usableRectTracker to signal instability, but
HitBox.update() skips re-registration when coordinates haven't changed,
causing waitUsableRectUpdate to never resolve for unchanged block positions.

New approach: explicit $pendingEntitiesUpdate signal set by markPendingUpdate(),
cleared by processQueue. usableRectTracker is never cleared on setEntities.

markPendingUpdate() no longer eagerly schedules processQueue — doing so caused
it to fire in the same rAF frame (before MEDIUM/component renders), clearing
$pendingEntitiesUpdate before new block hitboxes were queued. Instead,
processQueue fires naturally when hitbox updates arrive from component renders.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Reorder unsubRect/unsubPending declarations before cleanup closure to
resolve use-before-define warnings. Suppress callback-return warning
with inline eslint comment. Auto-fix formatting in test file.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@draedful draedful requested a review from Antamansid as a code owner May 12, 2026 10:19
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 12, 2026

Reviewer's Guide

Introduces an explicit pending-entities signal in HitTest and wires Graph and the scheduler around it so waitUsableRectUpdate correctly waits for entity replacement without clearing usableRectTracker, plus adds targeted unit/integration tests and design docs.

File-Level Changes

Change Details Files
Add explicit pending-entities state to HitTest and integrate it into stability and usable-rect waiting logic.
  • Introduce a private $pendingEntitiesUpdate signal alongside $usableRect, with a new public markPendingUpdate() API to flip it
  • Extend the isUnstable getter to consider $pendingEntitiesUpdate both for empty and non-empty graphs
  • Ensure processQueue and clear() both reset $pendingEntitiesUpdate when processing completes or HitTest is cleared
  • Update waitUsableRectUpdate to subscribe to both $usableRect and $pendingEntitiesUpdate and to manage subscriptions via a shared cleanup function
src/services/HitTest.ts
Change Graph.setEntities to mark hit testing as pending instead of zeroing usableRect, preserving existing usableRectTracker data.
  • Replace the call to hitTest.resetUsableRect() in setEntities with hitTest.markPendingUpdate()
  • Adjust comments to describe the new pending-update behavior and the fact that usableRectTracker is not cleared
src/graph.ts
Tighten scheduler debounce semantics so debounced callbacks see correct scheduling state during execution.
  • Refactor debounce.flush() to clear isScheduled and associated bookkeeping before invoking the wrapped function
  • Preserve and call the previous removeScheduler after fn() runs to avoid stale scheduler handles while keeping callback execution semantics the same
src/utils/utils/schedule.ts
Add focused tests validating HitTest pending-update semantics and Graph+HitTest integration for setEntities plus waitUsableRectUpdate.
  • Introduce HitTest.test.ts with helpers to seed usableRect and trigger processQueue, and tests covering markPendingUpdate effects on isUnstable and waitUsableRectUpdate resolution
  • Augment graph.test.ts with integration tests ensuring waitUsableRectUpdate resolves correctly after setEntities with unchanged block positions and with new blocks
src/services/HitTest.test.ts
src/graph.test.ts
Document the design and implementation plan for the pending-entities update change to HitTest and Graph.
  • Add a detailed implementation plan document describing the stepwise changes for introducing $pendingEntitiesUpdate and markPendingUpdate
  • Add a design spec explaining the root cause of the original bug, the new architecture, and what behaviors remain unchanged
docs/superpowers/plans/2026-05-07-hittest-pending-entities.md
docs/superpowers/specs/2026-05-07-hittest-pending-entities-design.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In debounce.flush, calling fn(...args) before currentRemoveScheduler() means any re-scheduling done inside fn can be unintentionally canceled by the stale currentRemoveScheduler; consider clearing state and calling currentRemoveScheduler first, then invoking fn, or otherwise guarding against canceling a newly scheduled run.
  • waitUsableRectUpdate now subscribes to both $usableRect and $pendingEntitiesUpdate and only unsubscribes when isUnstable becomes false; if a caller never invokes the returned cleanup and the hit test instance is long-lived, this can lead to lingering subscribers, so you may want a more centralized teardown path (e.g., tying these subscriptions into the clear/destroy lifecycle).
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `debounce.flush`, calling `fn(...args)` before `currentRemoveScheduler()` means any re-scheduling done inside `fn` can be unintentionally canceled by the stale `currentRemoveScheduler`; consider clearing state and calling `currentRemoveScheduler` first, then invoking `fn`, or otherwise guarding against canceling a newly scheduled run.
- `waitUsableRectUpdate` now subscribes to both `$usableRect` and `$pendingEntitiesUpdate` and only unsubscribes when `isUnstable` becomes false; if a caller never invokes the returned cleanup and the hit test instance is long-lived, this can lead to lingering subscribers, so you may want a more centralized teardown path (e.g., tying these subscriptions into the `clear`/destroy lifecycle).

## Individual Comments

### Comment 1
<location path="src/services/HitTest.ts" line_range="97-100" />
<code_context>
+Replace with:
+```typescript
+    if (hasZeroUsableRect && !this.hasGraphElements()) {
+      return hasProcessingQueue || this.$pendingEntitiesUpdate.value;
+    }
+```
</code_context>
<issue_to_address>
**issue:** Handling `setEntities` -> empty graph may leave `isUnstable` stuck true with no path to clear.

With the new `pendingEntitiesUpdate` flag, `isUnstable` returns `hasProcessingQueue || this.$pendingEntitiesUpdate.value` when `hasZeroUsableRect && !this.hasGraphElements()`. If `setEntities` is called with an empty blocks list, `markPendingUpdate()` sets the flag, but there may be no hitbox updates to run `processQueue`, so the flag might never be cleared and `waitUsableRectUpdate` would never resolve for an empty graph. Consider explicitly clearing `pendingEntitiesUpdate` when transitioning to an empty graph, or treating `!hasGraphElements` as stable even if the flag is true, so `waitUsableRectUpdate` remains usable for empty results.
</issue_to_address>

### Comment 2
<location path="src/services/HitTest.ts" line_range="235" />
<code_context>
+
+Current code (around lines 215–225):
+```typescript
+    if (this.isUnstable) {
+      const removeListener = this.$usableRect.subscribe(() => {
+        if (!this.isUnstable) {
</code_context>
<issue_to_address>
**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.

```ts
// 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:

```ts
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:

```ts
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.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/services/HitTest.ts
Comment thread src/services/HitTest.ts
@@ -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.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@gravity-ui-bot
Copy link
Copy Markdown
Contributor

Preview is ready.

Prevents a newly-scheduled debounced run from being accidentally
canceled when fn() triggers a re-schedule during flush execution.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@draedful draedful changed the title Fix/hittest pending entities update fix: hittest pending entities update May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants