Skip to content

fix(core): resolve table widget offsets and source at interaction time#128

Open
changdapeng wants to merge 1 commit into
floatboatai:mainfrom
changdapeng:fix/table-widget-stale-offsets
Open

fix(core): resolve table widget offsets and source at interaction time#128
changdapeng wants to merge 1 commit into
floatboatai:mainfrom
changdapeng:fix/table-widget-stale-offsets

Conversation

@changdapeng

Copy link
Copy Markdown

Summary / 摘要

When document content before a table changes, or the table itself is rewritten by a dispatch made under an editing lock (in-cell typing, range clears), the reused widget's event closures keep mutating the document against their construction-time tableFrom and source — silently doing nothing when the document got shorter (swallowed RangeError), corrupting unrelated text when it got longer, and reverting freshly typed cell content when a structural edit follows in-cell typing. Mutation paths now resolve both the live position (view.posAtDOM) and the live table source (re-slicing the block's line span from the current document) at the moment of interaction.

Motivation / 背景与动机

EditableTableWidget.eq() deliberately compares only source, so CM6 reuses the widget DOM whenever the table's own text is unchanged — a sensible optimization (rebuilding table DOM on every keystroke above it would be expensive). But the reused DOM's event listeners close over the widget instance that created them, and that instance's tableFrom goes stale as soon as anything before the table changes length (typing above it, editing a previous table). Every mutation path then dispatches against the stale offset:

  • document got shorter → RangeError: Invalid change range … is thrown inside the event listener and swallowed; the user's action silently does nothing;
  • document got longer → the stale range is still in-bounds, so the rewritten table lands inside unrelated text, eating part of it and leaving fragments of the old table behind.

Reproductions (both verified against main in a real browser):

  1. Two tables → delete a column in table 1 → drag a column in table 2 ⇒ RangeError, drag silently ignored.
  2. Intro + table → extend the intro text → drag a column ⇒ document becomes Intro —| b | c | a | … --- | --- | — the intro is partially eaten, old table fragments remain.
  3. Type into a cell → drag/delete a column without clicking away ⇒ the just-typed text is reverted and the replacement span is sized by a pre-typing snapshot.
  • Issue: none filed — discovered while regression-testing the table structural edits; happy to file one first if preferred.
  • Roadmap: n/a (bug fix)
  • OpenSpec change: n/a (bug fix, no public API change — one new private method)

Changes / 变更内容

  • packages/core:
    • src/live-preview-table.ts — the widget keeps a reference to its rendered wrapper; new private currentTableFrom(view) resolves the live start offset with view.posAtDOM (guarded by contentDOM.contains, wrapped in try/catch mirroring the existing getPosAtDOM idiom in editor.ts, falling back to the captured tableFrom), and currentTableSource() re-slices the live block by its line span (every widget-retaining edit preserves the table's line count). All mutation paths — dispatch(), the structural edits, addRow(), the cell-edit save — use them; the checkSelection rAF loop memoizes the lookups per document identity so idle frames stay pure arithmetic. eq() and the DOM-reuse optimization are intentionally untouched: the render-time source snapshot is exactly what eq() diffs against the next rebuild to refresh the DOM, so it must NOT be overwritten (documented in Table Widget rule 13).
  • CLAUDE.md — added Table Widget rule 13 encoding this invariant, following the file's convention of recording landmines discovered in live-preview-table.ts.
  • Tests: new packages/core/test/live-preview-table-offsets.test.ts — 4 cases driving the real event chains (no layout/coordinate dependency): cross-table sequential edits (shrinking document), text-above-grows-then-edit (asserts the surrounding prose survives byte-for-byte), type-into-cell-then-delete-column (asserts the typed text survives a structural edit made while the cell edit is still hot), and a no-prior-edit baseline guarding first-interaction behavior.

Testing / 测试

  • pnpm test passes — 519/519 (515 on main + 4 new)
  • Affected packages build (pnpm build for @floatboat/nexus-core)
  • pnpm typecheck — zero errors; git diff --check clean
  • Manual UI check in electron-demo (real browser): both reproduction sequences no longer reproduce; selection overlay follows the live table span after text above it changes; console clean

Compliance / 合规自检

  • CLA signed
  • AI disclosure: the functional code in this PR is not primarily generated by AI. AI assistance, if any, is described below.
    AI-assisted notes / AI 使用说明: The functional code in this PR was primarily developed by AI (Claude Code) under my direction; I reviewed it and take responsibility for every line.
  • New dependencies: none
  • No build artifacts committed
  • No secrets committed

Checklist / 自检清单

  • Title follows Conventional Commits
  • No public API change (private method only)
  • Touched live-preview-table.ts → walked through the 12 Table Widget rules: eq(), editing locks, rAF clear conditions, drag lifecycle and onDragEnd ordering all untouched; the diff adds offset resolution only
  • Not a new capability → no OpenSpec proposal needed
  • In scope per GOVERNANCE §4 (core bug fix)

Relationship to the ragged-tables PR

Logically independent fix, branched directly off main. Textually the two PRs edit the same structural-edit method bodies, so whichever lands second needs a mechanical rebase: the union is passing this PR's live source as the other PR's transform input, e.g. this.dispatch(deleteTableColumn(this.currentTableSource(), colIdx)).

eq() deliberately compares only `source` so CM6 reuses the table DOM
when the table's own text is unchanged, and locked dispatches (in-cell
typing under the focus lock, range clears under the range lock) keep
the widget instance alive per the StateField's decos.map path. But the
reused DOM's event closures reference the widget instance that created
them, and its captured `tableFrom` and `source` go stale: `tableFrom`
as soon as content before the table changes length, `source` as soon
as a locked dispatch rewrites the table itself. Every mutation path
then dispatches against stale values — when the document got shorter
the dispatch throws a swallowed RangeError and the edit silently does
nothing; when it grew, the change lands inside unrelated text and
corrupts it; a structural edit right after in-cell typing rebuilds the
table from a pre-typing snapshot and reverts what was just typed.

Keep the DOM-reuse optimization and the eq() snapshot semantics intact
and instead resolve both values live at the moment of interaction:
`currentTableFrom` via view.posAtDOM (falling back to the captured
offset), and `currentTableSource` by re-slicing the block's line span
out of the current document (every widget-retaining edit preserves the
table's line count). The per-frame selection poll memoizes the lookups
per document identity so idle frames stay pure arithmetic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoRNuUB2DWz7csbpNvABnz
@CLAassistant

CLAassistant commented Jul 4, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

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