Skip to content

feat: improve editor performance#14429

Merged
darkskygit merged 7 commits intocanaryfrom
darksky/improve-editor-performance
Feb 13, 2026
Merged

feat: improve editor performance#14429
darkskygit merged 7 commits intocanaryfrom
darksky/improve-editor-performance

Conversation

@darkskygit
Copy link
Member

@darkskygit darkskygit commented Feb 13, 2026

PR Dependency Tree

This tree was auto-generated by Charcoal

Summary by CodeRabbit

  • New Features

    • HTML import now splits lines on
      into separate paragraphs while preserving inline formatting.
  • Bug Fixes

    • Paste falls back to inserting after the first paragraph when no explicit target is found.
  • Style

    • Improved page-mode viewport styling for consistent content layout.
  • Tests

    • Added snapshot tests for
      -based paragraph splitting; re-enabled an e2e drag-page test.
  • Chores

    • Deferred/deduplicated font loading, inline text caching, drag-handle/pointer optimizations, and safer inline render synchronization.

@darkskygit darkskygit requested a review from a team as a code owner February 13, 2026 10:32
@github-actions github-actions bot added test Related to test cases app:core labels Feb 13, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds HTML
-driven paragraph splitting with inline-style preservation; deferred, deduplicated batched font loading; RAF-buffered and pointer-cached drag-handle updates; inline-text caching with MutationObserver and retrying point conversion; coordinated remote inline-range sync; paste fallback when no target; tests and styling updates.

Changes

Cohort / File(s) Summary
HTML Adapter Tests
blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts
Adds tests asserting paragraphs with <br> split into multiple paragraph blocks and that inline styles are preserved per split line in HtmlAdapter snapshot conversion.
HTML Adapter Implementation
blocksuite/affine/blocks/paragraph/src/adapters/html.ts
Adds delta-splitting helpers (splitDeltaByNewline, getParagraphDeltas, openParagraphBlocks), context keys to track multi-paragraph emissions, and updates traversal to emit multiple paragraph blocks for <br>/newline scenarios while avoiding duplicate emissions.
Font Loader Service
blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts
Implements deferred, batched font loading with per-font deduplication, idle/timeout scheduling, eager-first loading, cancellation on unmount, and exports FontConfigIdentifier and FontConfigExtension.
Drag-handle — Edgeless Watcher
blocksuite/affine/widgets/drag-handle/src/watchers/edgeless-watcher.ts
Introduces HoveredElemArea, RAF-buffered update flow, area clone/equality helpers, deferred show/hide/layout application, last-applied-area tracking to avoid redundant DOM updates, and disposal cleanup.
Drag-handle — Pointer Event Watcher
blocksuite/affine/widgets/drag-handle/src/watchers/pointer-event-watcher.ts
Adds per-pointer-position caching (_lastPointerHitBlockId, _lastPointerHitBlockElement) and early-exit logic to skip repeated expensive lookups while pointer remains over the same block.
Inline Text Caching
blocksuite/framework/std/src/inline/utils/text.ts
Adds InlineRootTextCache type, per-root WeakMap, lazy cache initialization with MutationObserver, build/rebuild and invalidate helpers for text nodes, index maps, prefix lengths, and line index mapping.
Point Conversion
blocksuite/framework/std/src/inline/utils/point-conversion.ts
Uses getInlineRootTextCache with a two-attempt retry (invalidate & retry) to resolve text/node indices, computes index via prefixLengths and lineIndexMap, and falls back to linear scan when necessary.
Inline Render / Yjs Sync
blocksuite/framework/std/src/inline/services/render.ts
Decouples remote inline-range sync from render: introduces _syncRemoteInlineRange, _onYTextChange pending-remote flags, renderVersion guards, throttled validation, and guarded post-render sync to avoid races and IME issues.
Clipboard Page Paste
blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts
Adds hasPasteTarget detection and a fallback paste-after-first-paragraph path when no target exists; changes ctx.blockIndex check to explicit !== undefined to handle falsy indices like 0.
Integration Test API Callsites
blocksuite/integration-test/src/__tests__/edgeless/frame.spec.ts
Updates tests to call new tuple-based API toModelCoordFromClientCoord([x, y]) instead of toModelCoord(x, y).
Detail Page Styling
packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.css.ts, packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx
Adds pageModeViewportContentBox style, global first-child rule (display: table, minWidth: 100%), and conditionally applies the class when mode === 'page'.
E2E Flavors Tests
packages/backend/server/src/__tests__/e2e/apps/flavors.spec.ts
Parametrizes flavor tests with createFlavorApp(flavor), adds a TestFlavor union and JobExecutor mock injection, and includes extra assertions for specific flavors.
E2E Drag Test Unskip
tests/affine-local/e2e/drag-page.spec.ts
Re-enabled a previously skipped E2E test by removing test.skip, no logic changes beyond enabling the test.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nudge the fonts to wake in queues,
BRs split lines and keep their boldest hues,
RAFs hush handles while caches mind each text,
Syncs wait their turn — no races vexed,
A tiny hop — the patch is fresh and true.

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The PR title 'feat: improve editor performance' is vague and generic, failing to summarize the actual specific changes present in the changeset. Provide a more specific title that reflects the actual primary changes, such as 'feat: optimize inline text caching and improve drag-handle rendering' or similar that indicates the concrete performance improvements implemented.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into canary

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch darksky/improve-editor-performance

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
tests/affine-local/e2e/drag-page.spec.ts (1)

233-234: Stale comment: remove or update the skip rationale now that the test is re-enabled.

Line 233 still reads // some how this test always timeout, so we skip it, but the test is no longer skipped. This is misleading for future readers.

Suggested fix
-// some how this test always timeout, so we skip it
 test('drag a page link in editor to favourites', async ({ page }) => {

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@blocksuite/affine/blocks/paragraph/src/adapters/html.ts`:
- Around line 190-201: The leave handler for case 'p' must skip calling
walkerContext.closeNode() when enter emitted multiple self-contained blocks; in
the enter branch where you compute delta via deltaConverter.astToDelta(o.node),
call getParagraphDeltas(o.node, delta) and if deltas.length > 1, set a
local/global flag on walkerContext (e.g.,
walkerContext.setLocalContext('p:multi', true)) before calling
openParagraphBlocks(...) and walkerContext.skipAllChildren(); then in the
corresponding leave handler for 'p' check that flag
(walkerContext.getLocalContext('p:multi') or clear it) and return early (skip
closeNode()) when it's true so you don't attempt to close a node that was
already closed by openParagraphBlocks.

In `@blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts`:
- Line 119: The forEach callback in the FontLoaderService is using an
expression-bodied arrow which implicitly returns the boolean from
document.fonts.delete(), triggering Biome; change the callback to a block body
or a for...of loop to avoid implicit return—e.g., update the
this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace)) call in the
font cleanup logic to use a block-bodied arrow (call
document.fonts.delete(fontFace) inside braces) or replace it with a for (const
fontFace of this.fontFaces) loop so no value is implicitly returned.

In `@blocksuite/framework/std/src/inline/utils/point-conversion.ts`:
- Around line 79-81: textPointToDomPoint can see stale cache within the same
synchronous task because MutationObserver runs asynchronously; when
textNodeIndexMap.get(text) returns undefined you should avoid silently returning
null for newly-added nodes by synchronously invalidating/rebuilding the inline
root cache. Modify textPointToDomPoint (or its caller) to call
invalidateInlineRootTextCache(rootElement) and rebuild
textNodeIndexMap/prefixLengths when textNodeIndexMap.get(text) is undefined (or
add an explicit invalidateInlineRootTextCache(rootElement) at mutation sites
where DOM changes occur), ensuring the dirty flag and maps are refreshed before
returning.
🧹 Nitpick comments (9)
packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.css.ts (1)

50-54: The !important on display: 'table' is fragile — consider documenting why it's needed.

Using display: table + minWidth: 100% as a layout optimization is a known pattern, but the !important flag and the :first-child selector couple this to a specific DOM structure and competing style. A brief comment explaining what style is being overridden and why table layout helps performance here would aid future maintainers.

blocksuite/affine/blocks/paragraph/src/adapters/html.ts (2)

40-75: splitDeltaByNewline can produce empty delta arrays for consecutive newlines.

When insert is exactly '\n' or contains consecutive '\n' characters, the function produces empty DeltaInsert[] entries (e.g., [[], []]). These flow into openParagraphBlocks and emit paragraph blocks with empty deltas. If this is intentional (to represent blank lines from <br><br>), it's fine. If not, consider filtering out empty arrays before returning.

-  return lines;
+  return lines.filter(line => line.length > 0);

110-130: walkerContext is typed as any.

Consider using the actual walker context type from the adapter framework instead of any to preserve type safety. This would catch misuse at compile time (e.g., calling openNode with wrong arguments).

blocksuite/framework/std/src/inline/utils/text.ts (3)

11-56: Good caching design with WeakMap and lazy rebuild.

The cache structure is well thought out: WeakMap for DOM node keys avoids preventing GC, prefixLengths avoids recomputation, and the dirty-flag pattern with MutationObserver is a clean invalidation strategy.

One observation: buildInlineRootTextCache duplicates the text node extraction logic from getTextNodesFromElement (lines 90–104). Consider reusing getTextNodesFromElement inside buildInlineRootTextCache to keep a single source of truth for text node discovery.

♻️ Proposed refactor to reuse getTextNodesFromElement

This would require a small internal variant of getTextNodesFromElement that also yields each node's parent text-span element, or you could refactor buildInlineRootTextCache to call getTextNodesFromElement(rootElement) and then look up parent info. Example sketch:

 const buildInlineRootTextCache = (
   rootElement: HTMLElement,
   cache: InlineRootTextCache
 ) => {
-  const textSpanElements = Array.from(
-    rootElement.querySelectorAll('[data-v-text="true"]')
-  );
-  const textNodes: Text[] = [];
-  const textNodeIndexMap = new WeakMap<Text, number>();
-  const prefixLengths: number[] = [];
-  let prefixLength = 0;
-
-  for (const textSpanElement of textSpanElements) {
-    const textNode = Array.from(textSpanElement.childNodes).find(
-      (node): node is Text => node instanceof Text
-    );
-    if (!textNode) continue;
-    prefixLengths.push(prefixLength);
-    textNodeIndexMap.set(textNode, textNodes.length);
-    textNodes.push(textNode);
-    prefixLength += calculateTextLength(textNode);
-  }
+  const textNodes = getTextNodesFromElement(rootElement);
+  const textNodeIndexMap = new WeakMap<Text, number>();
+  const prefixLengths: number[] = [];
+  let prefixLength = 0;
+
+  for (let i = 0; i < textNodes.length; i++) {
+    const textNode = textNodes[i];
+    prefixLengths.push(prefixLength);
+    textNodeIndexMap.set(textNode, i);
+    prefixLength += calculateTextLength(textNode);
+  }

58-88: MutationObserver is never disconnected — potential resource concern for long-lived roots.

The observer is created once per rootElement and never disconnected. While the WeakMap keying on rootElement means the cache can be GC'd when the element is collected, if the root element has a long lifecycle (e.g., it's reused or kept alive), the observer will persist indefinitely. If rootElement is ever detached and reattached, or if the inline editor is "disposed" while the element remains referenced, the observer continues to fire and mark dirty.

Consider adding an explicit teardown method (e.g., clearInlineRootTextCache(rootElement)) that disconnects the observer and deletes the cache entry, to be called during editor disposal.


72-81: MutationObserver doesn't watch attributes — could miss dynamic data-v-text attribute changes.

The observer watches childList and characterData but not attributes. If data-v-text attributes are dynamically toggled on existing elements (without adding/removing elements), the cache won't be invalidated. Currently, the codebase only sets this attribute once during component creation, so this is not an immediate concern. If attribute-based dynamic changes are introduced later, consider adding attributeFilter: ['data-v-text'] to the observer options.

blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts (3)

41-68: Consider extracting the Window type augmentation to reduce duplication.

The requestIdleCallback/cancelIdleCallback type cast appears in both _scheduleDeferredLoad and _cancelDeferredLoad. A shared type alias or a single cast at the class level would be cleaner.

♻️ Suggested refactor
+type WindowWithIdleCallback = Window & {
+  requestIdleCallback?: (
+    callback: () => void,
+    options?: { timeout?: number }
+  ) => number;
+  cancelIdleCallback?: (handle: number) => void;
+};
+
 export class FontLoaderService extends LifeCycleWatcher {

Then use const win = window as WindowWithIdleCallback; in both methods.

Also applies to: 70-90


111-112: Nit: extract the eager-font count to a named constant.

The magic number 3 on line 111 isn't self-documenting. A constant like EAGER_LOAD_COUNT alongside DEFERRED_LOAD_DELAY_MS would clarify intent.


33-35: The ready getter behavior should be clarified in documentation rather than flagged as a breaking change.

The ready getter does dynamically reflect the current fontFaces array state, so a promise obtained immediately after mount will initially cover only the 3 eager fonts. However, the actual usage pattern in the codebase (edgeless-root-block.ts calls this.fontLoader.ready during firstUpdated() and awaits it immediately) is unaffected by deferred loading.

The semantic shift is real but the practical impact is minimal: consumers obtain the ready promise fresh during initialization rather than caching it early, so they don't encounter the scenario where deferred fonts arrive after a cached promise has already resolved. To prevent future issues, consider:

  • Documenting that ready includes only currently-loaded fonts
  • Adding a comment explaining the deferred loading strategy
  • Or, provide a separate method that guarantees all configured fonts (eager + deferred)

@codecov
Copy link

codecov bot commented Feb 13, 2026

Codecov Report

❌ Patch coverage is 62.34940% with 125 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.52%. Comparing base (98e5747) to head (0e4a43d).
⚠️ Report is 1 commits behind head on canary.

Files with missing lines Patch % Lines
...dgets/drag-handle/src/watchers/edgeless-watcher.ts 43.58% 29 Missing and 15 partials ⚠️
...suite/affine/blocks/paragraph/src/adapters/html.ts 79.41% 4 Missing and 10 partials ⚠️
...framework/std/src/inline/utils/point-conversion.ts 26.31% 10 Missing and 4 partials ⚠️
...ed/src/services/font-loader/font-loader-service.ts 83.33% 6 Missing and 7 partials ⚠️
...ksuite/framework/std/src/inline/services/render.ts 45.83% 9 Missing and 4 partials ⚠️
.../drag-handle/src/watchers/pointer-event-watcher.ts 25.00% 12 Missing ⚠️
...affine/blocks/root/src/clipboard/page-clipboard.ts 0.00% 11 Missing ⚠️
blocksuite/framework/std/src/inline/utils/text.ts 89.47% 3 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           canary   #14429      +/-   ##
==========================================
+ Coverage   51.70%   53.52%   +1.81%     
==========================================
  Files        2828     2831       +3     
  Lines      152747   153338     +591     
  Branches    22527    22981     +454     
==========================================
+ Hits        78985    82070    +3085     
+ Misses      70609    68056    -2553     
- Partials     3153     3212      +59     
Flag Coverage Δ
server-test 75.09% <ø> (+3.56%) ⬆️
unittest 32.76% <62.34%> (+0.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts`:
- Around line 164-180: The fallback paste path can call this.std.clipboard.paste
with undefined parent context when document.querySelector('affine-page-root') or
focusFirstParagraph() fails; update the code around firstParagraphId/parentModel
to bail out early if parentModel is not resolved (e.g., if !firstParagraphId ||
!parentModel then return or skip paste) so paste(e, this.std.store,
parentModel?.id, insertIndex) is only invoked when a valid parentModel and
insertIndex exist; adjust the logic that obtains firstParagraphId (via
focusFirstParagraph) and parentModel and ensure paste is only called when
parentModel.id is defined.
🧹 Nitpick comments (11)
blocksuite/framework/std/src/inline/utils/text.ts (2)

26-43: DRY: buildInlineRootTextCache duplicates the text-node extraction logic from getTextNodesFromElement.

Both functions query [data-v-text="true"] and extract child Text nodes using the same pattern. Consider having buildInlineRootTextCache call getTextNodesFromElement (or a shared helper) to avoid the duplication.

♻️ Sketch
 const buildInlineRootTextCache = (
   rootElement: HTMLElement,
   cache: InlineRootTextCache
 ) => {
-  const textSpanElements = Array.from(
-    rootElement.querySelectorAll('[data-v-text="true"]')
-  );
-  const textNodes: Text[] = [];
   const textNodeIndexMap = new WeakMap<Text, number>();
   const prefixLengths: number[] = [];
   let prefixLength = 0;
-
-  for (const textSpanElement of textSpanElements) {
-    const textNode = Array.from(textSpanElement.childNodes).find(
-      (node): node is Text => node instanceof Text
-    );
-    if (!textNode) continue;
-    prefixLengths.push(prefixLength);
-    textNodeIndexMap.set(textNode, textNodes.length);
-    textNodes.push(textNode);
+  const textNodes = getTextNodesFromElement(rootElement);
+
+  for (const [i, textNode] of textNodes.entries()) {
+    prefixLengths.push(prefixLength);
+    textNodeIndexMap.set(textNode, i);
     prefixLength += calculateTextLength(textNode);
   }

Also applies to: 97-111


79-88: MutationObserver is never disconnected.

The observer is created once per root element and never disconnect()-ed. In practice the WeakMap + browser GC semantics should handle cleanup when the element is detached and unreferenced, but if root elements are swapped or recreated within the editor lifetime you may accumulate orphan observers.

Consider exposing a teardown path (e.g., disposeInlineRootTextCache(rootElement)) that calls observer.disconnect() and deletes the cache entry, to be invoked when an inline editor is destroyed.

blocksuite/framework/std/src/inline/utils/point-conversion.ts (1)

12-17: Note: getTextNodesFromElement is still imported alongside the new cache utilities.

getTextNodesFromElement is used by other functions in this file (nativePointToTextPoint, getTextPointRoughlyFromElement, etc.) that haven't been migrated to the cache. This is fine — just flagging that those call sites could benefit from the cache in a follow-up if they turn out to be hot paths.

blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts (3)

56-62: Duplicated window type augmentation.

The requestIdleCallback/cancelIdleCallback type extension is defined in both _scheduleDeferredLoad and _cancelDeferredLoad. Consider extracting a shared type alias at the module or class level.

♻️ Suggested refactor
+type IdleCallbackWindow = Window & {
+  requestIdleCallback?: (
+    callback: () => void,
+    options?: { timeout?: number }
+  ) => number;
+  cancelIdleCallback?: (handle: number) => void;
+};
+
 export class FontLoaderService extends LifeCycleWatcher {

Then use const win = window as IdleCallbackWindow; in both methods.

Also applies to: 102-104


84-89: Magic number 2000 for requestIdleCallback timeout.

The other timing values (DEFERRED_LOAD_DELAY_MS, DEFERRED_LOAD_BATCH_INTERVAL_MS) are named constants, but this timeout: 2000 is inline. Consider extracting it for consistency.


140-143: Hardcoded eager-font count 3.

The split between eager and deferred fonts is governed by the magic number 3. A named constant (e.g., EAGER_LOAD_COUNT) would make the intent clearer and the threshold easier to tune.

♻️ Suggested refactor
+  private static readonly EAGER_LOAD_COUNT = 3;
+
   // ...
 
   override mounted() {
     const config = this.std.getOptional(FontConfigIdentifier);
     if (!config || config.length === 0) {
       return;
     }
-    const eagerFonts = config.slice(0, 3);
-    const deferredFonts = config.slice(3);
+    const eagerFonts = config.slice(0, FontLoaderService.EAGER_LOAD_COUNT);
+    const deferredFonts = config.slice(FontLoaderService.EAGER_LOAD_COUNT);
     this.load(eagerFonts);
     this._scheduleDeferredLoad(deferredFonts);
   }
blocksuite/affine/blocks/root/src/clipboard/page-clipboard.ts (1)

166-168: Direct DOM query in fallback scenario is appropriate; tag coupling risk is minimal.

While document.querySelector('affine-page-root') does couple to a custom element tag, this is a legitimate fallback pattern used defensively when selection state is stale. The tag is stable, centrally defined, and consistently used across the codebase. No clearer framework-level alternative exists for directly accessing the page root in this context. If you prefer to reduce coupling, consider extracting the tag name to a constant shared with effects.ts:14, though the current approach is acceptable for this defensive code path.

blocksuite/framework/std/src/inline/services/render.ts (2)

62-70: Consider naming the throttle constant for clarity.

0x3f (63) is a bitmask that fires the \r validation every 64th event. This is a nice optimization, but the magic number is not self-documenting.

♻️ Optional: extract a named constant
+// Validate every 64 Y.Text events (bitmask: 0x3F = 63)
+private static readonly CR_VALIDATION_INTERVAL_MASK = 0x3f;
+
 ...
-    if (
-      (this._carriageReturnValidationCounter++ & 0x3f) === 0 &&
-      yText.toString().includes('\r')
-    ) {
+    if (
+      (this._carriageReturnValidationCounter++ & RenderService.CR_VALIDATION_INTERVAL_MASK) === 0 &&
+      yText.toString().includes('\r')
+    ) {

163-166: Lit render errors are silently swallowed.

The catch block recovers by calling rerenderWholeEditor(), but the original error is discarded. This could make it difficult to diagnose persistent IME-related rendering issues. Consider logging it before recovery.

♻️ Suggested: log before recovery
-    } catch {
+    } catch (error) {
       // Lit may be crashed by IME input and we need to rerender whole editor for it
+      console.warn('Lit render failed, attempting full re-render:', error);
       this.editor.rerenderWholeEditor();
     }
blocksuite/affine/widgets/drag-handle/src/watchers/edgeless-watcher.ts (1)

71-79: RAF-debounced surface updates with id filtering — good improvement.

_scheduleShowDragHandleFromSurfaceUpdate coalesces rapid surface/block updates into a single RAF. The subscription at line 374 filters by id to avoid processing updates for unrelated elements. The block update subscription (line 360) similarly checks payload.id.

One subtle point: the _surfaceElementUpdatedRafId RAF (line 74) only calls _showDragHandle(), which itself schedules another RAF (_showDragHandleRafId). This means a surface update results in a two-frame delay (one RAF to schedule, another to flush). If single-frame latency matters for visual feedback during element resizing/moving, consider calling _flushShowDragHandle directly from the surface update RAF.

Also applies to: 366-378

blocksuite/affine/blocks/paragraph/src/adapters/html.ts (1)

110-130: Type walkerContext parameter in three helper functions to improve compile-time safety.

openParagraphBlocks, markMultiParagraphEmitted, and consumeMultiParagraphEmittedMark currently accept walkerContext: any. Since ASTWalkerContext is importable from @blocksuite/store and this adapter converts to BlockSnapshot, these parameters should be typed as ASTWalkerContext<BlockSnapshot>. This would catch misuse at compile time (e.g., typos in openNode/closeNode/setGlobalContext calls). Other adapters in the codebase follow this pattern (e.g., image block adapter).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts`:
- Around line 42-44: The current ready getter on the FontLoaderService only
awaits the current entries in fontFaces (via fontFace.loaded) and thus resolves
before deferred batches are added; update the implementation so callers can
await "all configured fonts" by either (A) introducing an allFontsReady
Promise/Deferred that is resolved only when all deferred batches finish and have
been pushed into fontFaces (reference: add an allFontsReady property and resolve
it after the deferred batch completion code that populates fontFaces), or (B)
change the ready getter behaviour to track both existing fontFace.loaded
promises and any future batch-completion promise (e.g., combine
fontFaces.map(f=>f.loaded) with a batchCompletion promise) so it does not
resolve prematurely; alternatively if the original semantics are intended, add a
clear doc comment on the ready getter explaining it only reflects fonts loaded
so far. Ensure you modify the code paths that push deferred fonts into fontFaces
to resolve the allFontsReady/deferred signal when done.

In `@blocksuite/affine/widgets/drag-handle/src/watchers/pointer-event-watcher.ts`:
- Around line 175-178: When closestBlock is null you only clear
_lastPointerHitBlockId and widget.anchorBlockId.value but forget to clear the
cached DOM element; update the branch in pointer-event-watcher that handles the
null closestBlock to also set this._lastPointerHitBlockElement = null so both
caches (_lastPointerHitBlockId and _lastPointerHitBlockElement) are reset
together (matching the other reset paths that clear both fields).
🧹 Nitpick comments (3)
blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts (2)

50-76: Hardcoded critical-font list may drift from actual usage.

_isCriticalCanvasFont hardcodes specific FontFamily + FontWeight combinations (Poppins, Inter, Kalam). If font usage changes in the editor (e.g., a new default family or weight), this will silently fall through to the config.slice(0, 3) fallback, which may not pick the right fonts.

Consider co-locating the critical-font definitions with the font config or documenting the coupling so future changes don't accidentally regress initial-render quality.


85-91: Duplicate requestIdleCallback/cancelIdleCallback type augmentation.

The same Window & cast appears in both _scheduleDeferredLoad (line 85) and _cancelDeferredLoad (line 131). Extract a shared type alias to keep it DRY and consistent if the signature ever changes.

♻️ Suggested extraction
+type WindowWithIdleCallback = Window & {
+  requestIdleCallback?: (
+    callback: () => void,
+    options?: { timeout?: number }
+  ) => number;
+  cancelIdleCallback?: (handle: number) => void;
+};
+
 private readonly _scheduleDeferredLoad = (fonts: FontConfig[]) => {
     ...
-    const win = window as Window & {
-      requestIdleCallback?: (...) => number;
-      cancelIdleCallback?: (...) => void;
-    };
+    const win = window as WindowWithIdleCallback;
     ...
 };

 private readonly _cancelDeferredLoad = () => {
     ...
-    const win = window as Window & {
-      cancelIdleCallback?: (handle: number) => void;
-    };
+    const win = window as WindowWithIdleCallback;
     ...
 };

Also applies to: 131-133

blocksuite/framework/std/src/inline/utils/point-conversion.ts (1)

92-111: Good retry strategy addressing the MutationObserver timing gap.

The two-attempt loop with invalidateInlineRootTextCache on miss is a clean way to handle the async observer issue. One minor concern:

On line 97, return null inside the loop short-circuits both the retry (attempt 1) and the linear fallback (lines 115–131). If the cache is stale-empty (e.g. built before nodes were added, observer hasn't fired yet), we bail out without ever invalidating or falling through to the DOM-based scan — even though line 73 already confirmed rootElement.contains(text).

Replacing the early return with continue on the first attempt would let the invalidation + rebuild path run before giving up:

Suggested change
   for (let attempt = 0; attempt < 2; attempt++) {
     const { textNodes, textNodeIndexMap, prefixLengths, lineIndexMap } =
       getInlineRootTextCache(rootElement);
-    if (textNodes.length === 0) return null;
+    if (textNodes.length === 0) {
+      if (attempt === 0) {
+        invalidateInlineRootTextCache(rootElement);
+        continue;
+      }
+      break; // fall through to linear fallback
+    }

     const goalIndex = textNodeIndexMap.get(text);

This is an unlikely edge case given the containment guard, so feel free to defer.

@darkskygit darkskygit merged commit 72df9cb into canary Feb 13, 2026
66 checks passed
@darkskygit darkskygit deleted the darksky/improve-editor-performance branch February 13, 2026 16:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app:core app:server test Related to test cases

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant