Skip to content

perf: cache header/side offsets while computing cell geometry - #12

Open
sixbird wants to merge 1 commit into
appleple:masterfrom
sixbird:perf/cache-header-offsets
Open

perf: cache header/side offsets while computing cell geometry#12
sixbird wants to merge 1 commit into
appleple:masterfrom
sixbird:perf/cache-header-offsets

Conversation

@sixbird

@sixbird sixbird commented Aug 27, 2026

Copy link
Copy Markdown

Problem

getCellInfoByIndex(x, y) resolves the logical coordinates of one cell, but to do so it re-measures every .js-table-header th (one per column) and every .js-table-side (one per row):

const headers = this._getElementsByQuery('.js-table-header th');
const sides = this._getElementsByQuery('.js-table-side');
[].forEach.call(headers, (header, index) => { if (util.offset(header).left === left) { ... } });
[].forEach.call(sides,   (side,   index) => { if (util.offset(side).top   === top)  { ... } });

Every method that walks the whole table calls it once per cell, so a full scan costs cells x (cols + rows) getBoundingClientRect() calls, and each of those forces a layout flush. The scan happens on the hot paths:

  • update() -> beforeUpdated() -> markup() and onUpdated() -> getSelectedPoints() — i.e. every single click on a cell
  • selectRange() — on every mousemove while drag-selecting
  • mergeCells() / splitCell() / insertRow*() / insertCol*() / removeRow() / removeCol() / insertTable() (paste), several of which call getCellIndexByPos() per point, making them O(rows x cells^2)

On a 100 x 6 table this is ~65,000 getBoundingClientRect() calls for a single selection change, and the browser visibly freezes.

Fix

While a coordinate-reading method is running, take one snapshot of the header/side offsets and share it:

  • _buildGeometryCache() returns { headerLefts, sideTops }
  • _getGeometryCache() returns the snapshot when one is open, otherwise measures and returns a fresh one without storing it — so any path that is not wrapped behaves and costs exactly as before
  • _withGeometryCache(fn) opens a scope (_geoDepth++ / finally -> --_geoDepth === 0 -> drop). Nested calls share the outermost snapshot
  • The methods that read geometry more than once per call are wrapped through a small list at the bottom of the file, so their bodies are untouched and the wrapper is provably transparent (same arguments, same return value, same exceptions, same side effects)

Why the output cannot change

A snapshot is only ever used inside a single synchronous call, and it is dropped at every point where the DOM/layout is rewritten:

  • beforeUpdated() drops it before markup() re-measures
  • onUpdated() drops it on entry (the table has just been re-rendered by update()) and again at the end, because onUpdated() itself rewrites inner.style.width (9999px -> tableWidth) and thereby moves the layout
  • leaving the outermost scope always drops it (in a finally, so exceptions and the alert()/early-return paths are covered)

So a measurement can never outlive the layout it was taken from; the resolved (x, y) for any cell is identical to what the current code computes.

Deliberately not wrapped

method reason
updateTable() event dispatcher — its branches call update(), putCaret(), copy/paste, so the DOM changes inside it. Its inner calls are wrapped individually.
mergeCells() reads coordinates, then hands control back to the browser via confirm(), then reads again. Its expensive isSelectedCellsRectangle() call is wrapped, so it keeps essentially all of the win.
getClipBoardData() / processPaste() move DOM nodes directly / wait for pasted data with setTimeout.
getSelectedPoint(), getCellByPos(), copyTable() make only one geometry read each, and the reader they delegate to is already wrapped — wrapping them would add nothing.

Measurements

Deterministic call counts, measured against this patch in the vitest/jsdom setup (12 rows x 6 columns, 72 cells, getAllPoints()):

getBoundingClientRect() calls
before 1,440
after 91

The counts are cells x (cols + rows + 1) vs cols + rows + cells, i.e. for 100 x 6 it goes from ~64,800 to ~707.

Wall-clock, measured in headless Chrome 151 on a 100 x 6 table with the same technique applied to the bundled 1.5.12 build (identical code structure to 1.6.0):

operation before after
click (select one cell) 142 ms 33 ms
one mousemove while drag-selecting 184 ms 33 ms
merge cells 1,515 ms 122 ms
insert row 890 ms 81 ms
paste a 30 x 6 table 5,022 ms 475 ms
undo 138 ms 29 ms
paste into a 200-row table 19.4 s 1.4 s

Tests

npm run prepack (lint + tests + build) passes: 187 tests, 10 files — the 176 existing tests are untouched and green, plus 11 new ones in test/geometry-cache.test.js:

  • results are identical with and without the cache: getAllPoints() (plain grid and a colspan grid), getCellIndexByPos() for every cell, and the getTable() output after a sequence of selectRange -> insertColRight -> insertRowBelow -> selectRow -> removeCol
  • the cache reduces measurements: getAllPoints() on a 3 x 3 grid drops from 7 x 9 header/side reads to 7; nested calls (isSelectedCellsRectangle()) build the snapshot only once
  • the cache is dropped: after leaving a scope, after an exception, after update() (including update() called from inside an open scope, after which the next read re-measures), and it never leaks across calls — re-mocking the row layout between two getCellInfoByIndex() calls yields the new coordinates

概要 (日本語)

getCellInfoByIndex(x, y) はセル 1 個の論理座標を求めるたびに .js-table-header th(列数)と .js-table-side(行数)の座標を測り直しているため、全セルを走査する処理は セル数 x (列数 + 行数) 回の getBoundingClientRect() を発行します。これはセル選択(update() -> markup() / getAllPoints())、ドラッグ選択(mousemove ごとの selectRange())、結合・分割・行列追加・貼り付け(insertTable())といった主要な操作すべてが通る経路で、100 行 x 6 列では 1 クリックあたり約 65,000 回に達し、ブラウザが固まります。

対策として、座標を読むメソッドの実行中だけヘッダー / サイドの座標を 1 回スナップショットして共有します。スナップショットは _withGeometryCache() のスコープ内にしか存在せず、DOM が書き換わる境界(beforeUpdated()onUpdated() の冒頭と末尾)で必ず破棄するため、測った時点のレイアウトより長く生き残ることはなく、出力される HTML は変わりません。confirm() でブラウザに制御を返す mergeCells()、DOM を直接触る updateTable() / getClipBoardData() / processPaste() は意図的に対象外にしています(重い内側の呼び出しはキャッシュされます)。

既存の 176 テストはそのまま通り、キャッシュ有無での結果一致・計測回数の削減・破棄タイミングを確認する 11 件を追加しています。

getCellInfoByIndex() re-measured every `.js-table-header th` (one per
column) and every `.js-table-side` (one per row) each time a single
cell's logical (x, y) was resolved. The methods that walk the whole
table - markup(), getAllPoints(), getSelectedPoints(),
getCellIndexByPos() - therefore issued `cells x (cols + rows)`
getBoundingClientRect() calls, which made cell selection, drag
selection, merge/split, row/column insertion and paste quadratic in
table size and froze the browser on large tables.

Take one snapshot of the header/side offsets and share it for the
duration of a single synchronous coordinate-reading call, then drop it.
The snapshot only exists inside _withGeometryCache() scopes and is
discarded at every DOM-rewriting boundary (beforeUpdated(), and both
before and after onUpdated() rewrites the wrapper width), so no
measurement can outlive the layout it was taken from. Outside a scope
the offsets are read exactly as before.

Methods that are not wrapped on purpose: updateTable() (event
dispatcher whose branches re-render), mergeCells() (hands control back
to the browser through confirm(); its heavy isSelectedCellsRectangle()
call is cached anyway), getClipBoardData()/processPaste() (mutate the
DOM directly / wait for the paste asynchronously).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@uidev1116 uidev1116 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

パフォーマンス改善の方向性自体は良いと思います。細かい点をいくつかコメントしました。

Comment thread src/index.js
// 副作用はいずれも元のメソッドのまま変わらない。
//
// 一方、次のメソッドは意図的に対象外にしている。
// - updateTable: イベントの振り分け役で、分岐の中で update() や

@uidev1116 uidev1116 Aug 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Drag-selection (mousemove) doesn't benefit from this cache

Excluding updateTable() from the cached-method list makes sense on its own, but inside it, getSelectedPoints() (around line 846) is called unconditionally for every event type, and the mousemove branch (around line 885, selectRange()) opens a separate scope and recomputes the full geometry again.

Since points is only actually used in the mousedown / mouseup / touchstart branches, moving the getSelectedPoints() call inside those three branches should eliminate the double computation during dragging (where mousemove fires very frequently). Wrapping the whole function in the cache scope shouldn't be necessary.

Comment thread src/index.js
'getCellIndexByPos',
'markup',
'selectRange',
'selectRow',

@uidev1116 uidev1116 Aug 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The stated criterion for this list doesn't match reality

The comment right above this list says the criterion is "methods that call getCellInfoByIndex multiple times directly," but selectRow never calls getCellInfoByIndex directly. It calls getAllPoints() once, then loops calling getCellByPosgetCellIndexByPos (which internally scans every cell) multiple times — that's the actual reason it needs to share the cache scope.

This mismatch makes it easy for someone adding a similar method later (one that loops calling an already-wrapped helper multiple times) to misjudge it as "not applicable" just by reading the comment literally. Either rewrite the comment to match the real criterion, or consider flipping this from an allowlist to a denylist (exclude only updateTable / mergeCells / getClipBoardData / processPaste, and wrap everything else by default) to structurally prevent this kind of omission.

Comment thread src/index.js

onUpdated() {
// 直前に update() が DOM を作り直しているのでスナップショットは無効
this._geo = null;

@uidev1116 uidev1116 Aug 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this._geo = null invalidation is copy-pasted in 3 places

The same "discard the geometry cache" operation appears in two places inside onUpdated() (here and line 661) and once inside beforeUpdated() (line 1248), each with its own explanatory comment.

Since this PR already introduces named APIs (_buildGeometryCache / _getGeometryCache / _withGeometryCache), extracting a single _invalidateGeometryCache() method and calling it from all three places would reduce the risk of missing one spot if the cache's internal structure changes later.

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