Skip to content

feat: coalesced blocks-geometry-change on position updates#289

Merged
draedful merged 5 commits intomainfrom
feat/coalesced-blocks-geometry-events
Apr 25, 2026
Merged

feat: coalesced blocks-geometry-change on position updates#289
draedful merged 5 commits intomainfrom
feat/coalesced-blocks-geometry-events

Conversation

@draedful
Copy link
Copy Markdown
Collaborator

@draedful draedful commented Apr 21, 2026

Summary

  • Coalesce blocks-geometry-change to at most once per animation frame whenever block position updates via BlockListStore.updatePosition.
  • Keep block-change with a shallow block snapshot (asTBlockShallow) so preventDefault can skip the store update and the corresponding geometry batch entry.
  • React: onBlocksGeometryChange mapped to blocks-geometry-change.
  • MiniMap listens to blocks-geometry-change in addition to block-change.
  • Flush pending geometry on drag end (DragService).
  • E2E: e2e/tests/block/block-geometry-batching.spec.ts.

Notes

  • blocks-geometry-change payload: { id, x, y, width, height } per block only.
  • Export TBlockGeometrySnapshot type from the package root.

Made with Cursor

Summary by Sourcery

Introduce coalesced per-frame block geometry change events and wire them through the store, plugins, React API, and drag lifecycle.

New Features:

  • Add a blocks-geometry-change graph event that batches per-block geometry snapshots at most once per animation frame.
  • Expose a new onBlocksGeometryChange React callback mapped to the blocks-geometry-change graph event.
  • Export the TBlockGeometrySnapshot type from the package root for consumers.

Enhancements:

  • Emit block-change with a shallow block snapshot so preventDefault can cancel position updates and associated geometry batching.
  • Update the MiniMap layer to react to both block-change and blocks-geometry-change events for more efficient re-rendering.
  • Ensure group drags only update blocks when groups contain members and flush pending geometry batches on drag end.
  • Add a lightweight asTBlockShallow snapshot method on BlockState for event payloads.

Tests:

  • Add end-to-end tests covering block-change plus coalesced blocks-geometry-change behavior for single-block, multi-block, grouped drags, and preventDefault handling.

- Emit blocks-geometry-change at most once per frame for updatePosition
- Keep block-change with shallow TBlock snapshot (asTBlockShallow) for veto/sync
- Add onBlocksGeometryChange / blocks-geometry-change; minimap listens to both
- Flush pending geometry on drag end; add Playwright coverage

Made-with: Cursor
@draedful draedful requested a review from Antamansid as a code owner April 21, 2026 14:36
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Apr 21, 2026

Reviewer's Guide

Adds a coalesced blocks-geometry-change event stream for block position updates, wires it into the store, minimap, React callbacks, and drag service, and verifies the batching semantics with new Playwright e2e tests.

File-Level Changes

Change Details Files
Introduce lightweight geometry snapshots and RAF-batched blocks-geometry-change emission in the block list store.
  • Define TBlockGeometrySnapshot describing {id,x,y,width,height} for a block.
  • Track pending geometry updates in a Map and schedule coalesced emission via requestAnimationFrame.
  • Add methods to flush, schedule, and cancel batched geometry emissions, including a public flushBatchedBlocksGeometryEmit used by other services.
  • Ensure store reset clears any pending geometry batches.
src/store/block/BlocksList.ts
Expose a shallow block snapshot API and adjust block-change semantics to use it and support preventing geometry updates.
  • Add BlockState.asTBlockShallow returning a cheap, shallow snapshot that reuses nested references.
  • Change BlockListStore.updatePosition to emit block-change with the shallow snapshot and only enqueue geometry if the event is not prevented.
  • Clarify block-change JSDoc to describe the new semantics and limitations on mutating nested fields.
src/store/block/Block.ts
src/store/block/BlocksList.ts
Wire the new geometry batching event into React callbacks, minimap rendering, drag lifecycle, and public API.
  • Extend graph event typings with blocks-geometry-change carrying an array of geometry snapshots.
  • Map React onBlocksGeometryChange callback to the new graph event.
  • Update MiniMap layer to listen to both block-change and blocks-geometry-change via a shared handler for viewport recalculation and render.
  • Flush any pending geometry batches when drag ends in DragService to avoid losing the last frame.
  • Guard BlockGroups.updateBlocks against empty groups and export TBlockGeometrySnapshot from the package root.
src/store/block/BlocksList.ts
src/react-components/events.ts
src/plugins/minimap/layer.ts
src/services/drag/DragService.ts
src/components/canvas/groups/BlockGroups.ts
src/index.ts
Add end-to-end tests to validate geometry batching behavior and block-change preventDefault semantics.
  • Create Playwright scenarios covering single-block drag, multi-block drag, grouped drag, and preventDefault on block-change.
  • Assert that blocks-geometry-change fires less often than block-change while including all moved block IDs and only geometry fields.
  • Verify that preventing block-change stops both position updates and geometry batching.
e2e/tests/block/block-geometry-batching.spec.ts

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 3 issues, and left some high level feedback:

  • The batching logic in BlockListStore duplicates the Array.from(...values()) + clear + emit('blocks-geometry-change', { blocks }) sequence in both flushBatchedBlocksGeometryEmit and the rAF callback; consider extracting this into a small private helper (e.g. emitBatchedBlocksGeometryIfNeeded()) to keep behavior consistent and reduce future maintenance overhead.
  • Now that block-change receives a shallow snapshot via asTBlockShallow, it might be worth tightening the type (e.g. a Readonly<TBlock> alias) for that event detail to better enforce the “treat nested values as read-only” contract at compile time.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The batching logic in `BlockListStore` duplicates the `Array.from(...values())` + `clear` + `emit('blocks-geometry-change', { blocks })` sequence in both `flushBatchedBlocksGeometryEmit` and the rAF callback; consider extracting this into a small private helper (e.g. `emitBatchedBlocksGeometryIfNeeded()`) to keep behavior consistent and reduce future maintenance overhead.
- Now that `block-change` receives a shallow snapshot via `asTBlockShallow`, it might be worth tightening the type (e.g. a `Readonly<TBlock>` alias) for that event detail to better enforce the “treat nested values as read-only” contract at compile time.

## Individual Comments

### Comment 1
<location path="src/store/block/BlocksList.ts" line_range="27" />
<code_context>
     });
   }
+
+  /**
+   * Cheap snapshot for graph events (e.g. `block-change`): one object spread, no deep clone.
+   * Nested values (`anchors`, `meta`, `settings`, …) are shared with the live store — treat as read-only.
</code_context>
<issue_to_address>
**nitpick (typo):** Typo in JSDoc: "Emited" should be "Emitted".

Please update the JSDoc for the `block-change` event to use the correct spelling, "Emitted".
</issue_to_address>

### Comment 2
<location path="e2e/tests/block/block-geometry-batching.spec.ts" line_range="36-45" />
<code_context>
+  test("single-block drag emits both block-change and fewer blocks-geometry-change events", async ({ page }) => {
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a targeted test for the drag-end flush behaviour of blocks-geometry-change

The current test only verifies that `blocks-geometry-change` is emitted less often than `block-change`, which is a coarse confirmation of batching. To exercise the drag-end flush specifically, add a test that performs a very short drag with minimal/zero `waitFrames` and asserts that at least one `blocks-geometry-change` event occurs after mouseup, confirming `flushBatchedBlocksGeometryEmit` runs correctly at drag end.

Suggested implementation:

```typescript
    const readGeom = await graphPO.collectGraphEventDetails<{ blocks: { id: string }[] }>("blocks-geometry-change");

```

You’ll need to **add a new test** inside the existing `test.describe("block-change + coalesced blocks-geometry-change", () => { ... })` block, after the existing `"single-block drag emits both..."` test. The test should:

1. Initialize the graph as in the existing test (reuse `TWO_BLOCKS` and `DRAG_SETTINGS`):
   ```ts
   const graphPO = new GraphPageObject(page);
   await graphPO.initialize({
     blocks: TWO_BLOCKS,
     connections: [],
     settings: DRAG_SETTINGS,
   });
   ```

2. Begin collecting events **before** starting the drag:
   ```ts
   const readBlockChange = await graphPO.collectGraphEventDetails<{ block: { id: string } }>("block-change");
   const readGeom = await graphPO.collectGraphEventDetails<{ blocks: { id: string }[] }>("blocks-geometry-change");
   ```

3. Perform a very short drag on a single block with minimal batching opportunity (no `waitFrames` or `waitFrames: 0` if your helper requires it). For example, if your `GraphPageObject` has a drag helper similar to:
   ```ts
   await graphPO.dragBlockBy("a", { x: 5, y: 5 });
   ```
   or, if you have to be explicit:
   ```ts
   await graphPO.dragBlock("a", { to: { x: 105, y: 105 }, waitFrames: 0 });
   ```

4. After `mouseup` has occurred (i.e. after the drag helper resolves), stop collecting events and inspect them. You want to assert:
   - At least one `block-change` occurred during the drag.
   - At least one `blocks-geometry-change` event was emitted **after** the last `block-change` event, proving the drag-end flush.

   Assuming `collectGraphEventDetails` gives you an async iterator-style reader with `.readAll()` or a similar method that returns an array of events with timestamps, an example assertion might look like:

   ```ts
   test("short drag flushes blocks-geometry-change on drag end", async ({ page }) => {
     const graphPO = new GraphPageObject(page);
     await graphPO.initialize({
       blocks: TWO_BLOCKS,
       connections: [],
       settings: DRAG_SETTINGS,
     });

     const readBlockChange = await graphPO.collectGraphEventDetails<{ block: { id: string } }>("block-change");
     const readGeom = await graphPO.collectGraphEventDetails<{ blocks: { id: string }[] }>("blocks-geometry-change");

     // Perform a minimal drag on block "a" with no extra frame waits.
     await graphPO.dragBlockBy("a", { x: 5, y: 5 /*, waitFrames: 0 if required */ });

     const blockChangeEvents = await readBlockChange.readAll();
     const geomEvents = await readGeom.readAll();

     expect(blockChangeEvents.length).toBeGreaterThan(0);
     expect(geomEvents.length).toBeGreaterThan(0);

     // If events include timestamps (e.g. event.timestamp), assert that at least one
     // geometry-change event is emitted after the last block-change event.
     const lastBlockChangeTime = Math.max(...blockChangeEvents.map(e => e.timestamp));
     const geomAfterMouseup = geomEvents.filter(e => e.timestamp > lastBlockChangeTime);
     expect(geomAfterMouseup.length).toBeGreaterThan(0);
   });
   ```

5. If your event detail objects don’t yet carry timestamps, you can instead:
   - Use `performance.now()` or a monotonic counter injected in your test harness, or
   - Enforce ordering based on the collection semantics of `collectGraphEventDetails` (e.g. if it preserves sequence, you can assert that the last `blocks-geometry-change` is emitted after the last drag step, and that at least one such event exists even for a single step drag).

In summary, add a `test("short drag flushes blocks-geometry-change on drag end", ...)` to the suite, following the pattern above, and adapt:
- The drag helper (`dragBlockBy` / `dragBlock`), and
- How you read/inspect events (`readAll`, timestamps, etc.)
to match what `GraphPageObject` and `collectGraphEventDetails` actually expose in your codebase.
</issue_to_address>

### Comment 3
<location path="src/store/block/BlocksList.ts" line_range="259" />
<code_context>
+   * Flushes pending `blocks-geometry-change` immediately (cancels a scheduled rAF if any).
+   * Called when a graph drag ends so the last frame is not lost.
+   */
+  public flushBatchedBlocksGeometryEmit(): void {
+    if (this.batchedGeometryRaf !== null) {
+      cancelAnimationFrame(this.batchedGeometryRaf);
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting shared emit and requestAnimationFrame cleanup helpers so the batching code paths all reuse a single implementation.

You can reduce the extra moving parts without changing behavior by centralizing the flush logic and rAF handling. That addresses points (1) and (3) while keeping your current API surface (`flushBatchedBlocksGeometryEmit`, `scheduleBatchedBlocksGeometryFlush`, `cancelBatchedBlocksGeometry`) intact.

### 1. Deduplicate flush logic

Extract the shared “read map → clear → emit” sequence into a single helper:

```ts
private emitBatchedBlocksGeometry(): void {
  if (this.batchedGeometryPending.size === 0) {
    return;
  }
  const blocks = Array.from(this.batchedGeometryPending.values());
  this.batchedGeometryPending.clear();
  this.graph.emit("blocks-geometry-change", { blocks });
}
```

Use it in both the immediate flush and the rAF callback:

```ts
public flushBatchedBlocksGeometryEmit(): void {
  this.clearGeometryRaf();
  this.emitBatchedBlocksGeometry();
}

private scheduleBatchedBlocksGeometryFlush(): void {
  if (this.batchedGeometryRaf !== null) {
    return;
  }
  this.batchedGeometryRaf = requestAnimationFrame(() => {
    this.batchedGeometryRaf = null;
    this.emitBatchedBlocksGeometry();
  });
}
```

### 2. Centralize rAF management

Avoid setting/cancelling/clearing `batchedGeometryRaf` in multiple places by extracting that as well:

```ts
private clearGeometryRaf(): void {
  if (this.batchedGeometryRaf !== null) {
    cancelAnimationFrame(this.batchedGeometryRaf);
    this.batchedGeometryRaf = null;
  }
}
```

Then `cancelBatchedBlocksGeometry` becomes simpler and consistent:

```ts
private cancelBatchedBlocksGeometry(): void {
  this.clearGeometryRaf();
  this.batchedGeometryPending.clear();
}
```

Net effect:

- Core batching behavior is defined once in `emitBatchedBlocksGeometry`.
- The invariant “`batchedGeometryRaf !== null` means a flush is scheduled” is enforced in one helper.
- The public API methods stay the same, but the control flow is easier to follow and less error‑prone.
</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/store/block/BlocksList.ts
Comment on lines +36 to +45
test("single-block drag emits both block-change and fewer blocks-geometry-change events", async ({ page }) => {
const graphPO = new GraphPageObject(page);
await graphPO.initialize({
blocks: TWO_BLOCKS,
connections: [],
settings: DRAG_SETTINGS,
});

const readBlockChange = await graphPO.collectGraphEventDetails<{ block: { id: string } }>("block-change");
const readGeom = await graphPO.collectGraphEventDetails<{ blocks: { id: string }[] }>("blocks-geometry-change");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Consider adding a targeted test for the drag-end flush behaviour of blocks-geometry-change

The current test only verifies that blocks-geometry-change is emitted less often than block-change, which is a coarse confirmation of batching. To exercise the drag-end flush specifically, add a test that performs a very short drag with minimal/zero waitFrames and asserts that at least one blocks-geometry-change event occurs after mouseup, confirming flushBatchedBlocksGeometryEmit runs correctly at drag end.

Suggested implementation:

    const readGeom = await graphPO.collectGraphEventDetails<{ blocks: { id: string }[] }>("blocks-geometry-change");

You’ll need to add a new test inside the existing test.describe("block-change + coalesced blocks-geometry-change", () => { ... }) block, after the existing "single-block drag emits both..." test. The test should:

  1. Initialize the graph as in the existing test (reuse TWO_BLOCKS and DRAG_SETTINGS):

    const graphPO = new GraphPageObject(page);
    await graphPO.initialize({
      blocks: TWO_BLOCKS,
      connections: [],
      settings: DRAG_SETTINGS,
    });
  2. Begin collecting events before starting the drag:

    const readBlockChange = await graphPO.collectGraphEventDetails<{ block: { id: string } }>("block-change");
    const readGeom = await graphPO.collectGraphEventDetails<{ blocks: { id: string }[] }>("blocks-geometry-change");
  3. Perform a very short drag on a single block with minimal batching opportunity (no waitFrames or waitFrames: 0 if your helper requires it). For example, if your GraphPageObject has a drag helper similar to:

    await graphPO.dragBlockBy("a", { x: 5, y: 5 });

    or, if you have to be explicit:

    await graphPO.dragBlock("a", { to: { x: 105, y: 105 }, waitFrames: 0 });
  4. After mouseup has occurred (i.e. after the drag helper resolves), stop collecting events and inspect them. You want to assert:

    • At least one block-change occurred during the drag.
    • At least one blocks-geometry-change event was emitted after the last block-change event, proving the drag-end flush.

    Assuming collectGraphEventDetails gives you an async iterator-style reader with .readAll() or a similar method that returns an array of events with timestamps, an example assertion might look like:

    test("short drag flushes blocks-geometry-change on drag end", async ({ page }) => {
      const graphPO = new GraphPageObject(page);
      await graphPO.initialize({
        blocks: TWO_BLOCKS,
        connections: [],
        settings: DRAG_SETTINGS,
      });
    
      const readBlockChange = await graphPO.collectGraphEventDetails<{ block: { id: string } }>("block-change");
      const readGeom = await graphPO.collectGraphEventDetails<{ blocks: { id: string }[] }>("blocks-geometry-change");
    
      // Perform a minimal drag on block "a" with no extra frame waits.
      await graphPO.dragBlockBy("a", { x: 5, y: 5 /*, waitFrames: 0 if required */ });
    
      const blockChangeEvents = await readBlockChange.readAll();
      const geomEvents = await readGeom.readAll();
    
      expect(blockChangeEvents.length).toBeGreaterThan(0);
      expect(geomEvents.length).toBeGreaterThan(0);
    
      // If events include timestamps (e.g. event.timestamp), assert that at least one
      // geometry-change event is emitted after the last block-change event.
      const lastBlockChangeTime = Math.max(...blockChangeEvents.map(e => e.timestamp));
      const geomAfterMouseup = geomEvents.filter(e => e.timestamp > lastBlockChangeTime);
      expect(geomAfterMouseup.length).toBeGreaterThan(0);
    });
  5. If your event detail objects don’t yet carry timestamps, you can instead:

    • Use performance.now() or a monotonic counter injected in your test harness, or
    • Enforce ordering based on the collection semantics of collectGraphEventDetails (e.g. if it preserves sequence, you can assert that the last blocks-geometry-change is emitted after the last drag step, and that at least one such event exists even for a single step drag).

In summary, add a test("short drag flushes blocks-geometry-change on drag end", ...) to the suite, following the pattern above, and adapt:

  • The drag helper (dragBlockBy / dragBlock), and
  • How you read/inspect events (readAll, timestamps, etc.)
    to match what GraphPageObject and collectGraphEventDetails actually expose in your codebase.

Comment thread src/store/block/BlocksList.ts
@gravity-ui-bot
Copy link
Copy Markdown
Contributor

Preview is ready.

@draedful draedful merged commit 52b50d2 into main Apr 25, 2026
6 checks passed
@draedful draedful deleted the feat/coalesced-blocks-geometry-events branch April 25, 2026 17:40
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