fix: guard table resize against stale editor views

This commit is contained in:
lucaronin
2026-04-11 16:37:44 +02:00
parent 798a6b2125
commit cd9e5fc403
6 changed files with 371 additions and 5 deletions

View File

@@ -0,0 +1,234 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 6c65eb7d57e207a8cd1f2a2ae3bb99507c405cef..c163932957846385623eb2980bf1141eb6631db1 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -2443,6 +2443,21 @@ function handleMouseLeave(view) {
const pluginState = columnResizingPluginKey.getState(view.state);
if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1);
}
+function safeDispatch(view, tr) {
+ try {
+ view.dispatch(tr);
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+function safeDomAtPos(view, pos) {
+ try {
+ return view.domAtPos(pos);
+ } catch (_error) {
+ return null;
+ }
+}
function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
var _view$dom$ownerDocume;
if (!view.editable) return false;
@@ -2451,17 +2466,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false;
const cell = view.state.doc.nodeAt(pluginState.activeHandle);
const width = currentColWidth(view, pluginState.activeHandle, cell.attrs);
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
+ if (width == null) return false;
+ if (!safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
startX: event.clientX,
startWidth: width
- } }));
+ } }))) return false;
function finish(event$1) {
win.removeEventListener("mouseup", finish);
win.removeEventListener("mousemove", move);
const pluginState$1 = columnResizingPluginKey.getState(view.state);
if (pluginState$1 === null || pluginState$1 === void 0 ? void 0 : pluginState$1.dragging) {
updateColumnWidth(view, pluginState$1.activeHandle, draggedWidth(pluginState$1.dragging, event$1, cellMinWidth));
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
}
}
function move(event$1) {
@@ -2482,15 +2498,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
function currentColWidth(view, cellPos, { colspan, colwidth }) {
const width = colwidth && colwidth[colwidth.length - 1];
if (width) return width;
- const dom = view.domAtPos(cellPos);
- let domWidth = dom.node.childNodes[dom.offset].offsetWidth, parts = colspan;
+ const dom = safeDomAtPos(view, cellPos);
+ if (!dom) return null;
+ const cellDom = dom.node && dom.node.childNodes ? dom.node.childNodes[dom.offset] : null;
+ if (!cellDom || typeof cellDom.offsetWidth != "number") return null;
+ let domWidth = cellDom.offsetWidth, parts = colspan;
if (colwidth) {
for (let i = 0; i < colspan; i++) if (colwidth[i]) {
domWidth -= colwidth[i];
parts--;
}
}
- return domWidth / parts;
+ return parts ? domWidth / parts : null;
}
function domCellAround(target) {
while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode;
@@ -2516,10 +2535,15 @@ function draggedWidth(dragging, event, resizeMinWidth) {
return Math.max(resizeMinWidth, dragging.startWidth + offset);
}
function updateHandle(view, value) {
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
}
function updateColumnWidth(view, cell, width) {
- const $cell = view.state.doc.resolve(cell);
+ let $cell;
+ try {
+ $cell = view.state.doc.resolve(cell);
+ } catch (_error) {
+ return false;
+ }
const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1);
const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
const tr = view.state.tr;
@@ -2537,16 +2561,24 @@ function updateColumnWidth(view, cell, width) {
colwidth
});
}
- if (tr.docChanged) view.dispatch(tr);
+ if (tr.docChanged) return safeDispatch(view, tr);
+ return true;
}
function displayColumnWidth(view, cell, width, defaultCellMinWidth) {
- const $cell = view.state.doc.resolve(cell);
+ let $cell;
+ try {
+ $cell = view.state.doc.resolve(cell);
+ } catch (_error) {
+ return false;
+ }
const table = $cell.node(-1), start = $cell.start(-1);
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
- let dom = view.domAtPos($cell.start(-1)).node;
+ const domAtPos = safeDomAtPos(view, $cell.start(-1));
+ let dom = domAtPos && domAtPos.node;
while (dom && dom.nodeName != "TABLE") dom = dom.parentNode;
- if (!dom) return;
+ if (!dom) return false;
updateColumnsOnResize(table, dom.firstChild, dom, defaultCellMinWidth, col, width);
+ return true;
}
function zeroes(n) {
return Array(n).fill(0);
diff --git a/dist/index.js b/dist/index.js
index 5b4ac25594ba5722409332b1e5c812f108c9bf11..5b811ee70ff2fcb0c7587f838f00bc6fc19e906a 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -2443,6 +2443,21 @@ function handleMouseLeave(view) {
const pluginState = columnResizingPluginKey.getState(view.state);
if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1);
}
+function safeDispatch(view, tr) {
+ try {
+ view.dispatch(tr);
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+function safeDomAtPos(view, pos) {
+ try {
+ return view.domAtPos(pos);
+ } catch (_error) {
+ return null;
+ }
+}
function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
var _view$dom$ownerDocume;
if (!view.editable) return false;
@@ -2451,17 +2466,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false;
const cell = view.state.doc.nodeAt(pluginState.activeHandle);
const width = currentColWidth(view, pluginState.activeHandle, cell.attrs);
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
+ if (width == null) return false;
+ if (!safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
startX: event.clientX,
startWidth: width
- } }));
+ } }))) return false;
function finish(event$1) {
win.removeEventListener("mouseup", finish);
win.removeEventListener("mousemove", move);
const pluginState$1 = columnResizingPluginKey.getState(view.state);
if (pluginState$1 === null || pluginState$1 === void 0 ? void 0 : pluginState$1.dragging) {
updateColumnWidth(view, pluginState$1.activeHandle, draggedWidth(pluginState$1.dragging, event$1, cellMinWidth));
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
}
}
function move(event$1) {
@@ -2482,15 +2498,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
function currentColWidth(view, cellPos, { colspan, colwidth }) {
const width = colwidth && colwidth[colwidth.length - 1];
if (width) return width;
- const dom = view.domAtPos(cellPos);
- let domWidth = dom.node.childNodes[dom.offset].offsetWidth, parts = colspan;
+ const dom = safeDomAtPos(view, cellPos);
+ if (!dom) return null;
+ const cellDom = dom.node && dom.node.childNodes ? dom.node.childNodes[dom.offset] : null;
+ if (!cellDom || typeof cellDom.offsetWidth != "number") return null;
+ let domWidth = cellDom.offsetWidth, parts = colspan;
if (colwidth) {
for (let i = 0; i < colspan; i++) if (colwidth[i]) {
domWidth -= colwidth[i];
parts--;
}
}
- return domWidth / parts;
+ return parts ? domWidth / parts : null;
}
function domCellAround(target) {
while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode;
@@ -2516,10 +2535,15 @@ function draggedWidth(dragging, event, resizeMinWidth) {
return Math.max(resizeMinWidth, dragging.startWidth + offset);
}
function updateHandle(view, value) {
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
}
function updateColumnWidth(view, cell, width) {
- const $cell = view.state.doc.resolve(cell);
+ let $cell;
+ try {
+ $cell = view.state.doc.resolve(cell);
+ } catch (_error) {
+ return false;
+ }
const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1);
const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
const tr = view.state.tr;
@@ -2537,16 +2561,24 @@ function updateColumnWidth(view, cell, width) {
colwidth
});
}
- if (tr.docChanged) view.dispatch(tr);
+ if (tr.docChanged) return safeDispatch(view, tr);
+ return true;
}
function displayColumnWidth(view, cell, width, defaultCellMinWidth) {
- const $cell = view.state.doc.resolve(cell);
+ let $cell;
+ try {
+ $cell = view.state.doc.resolve(cell);
+ } catch (_error) {
+ return false;
+ }
const table = $cell.node(-1), start = $cell.start(-1);
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
- let dom = view.domAtPos($cell.start(-1)).node;
+ const domAtPos = safeDomAtPos(view, $cell.start(-1));
+ let dom = domAtPos && domAtPos.node;
while (dom && dom.nodeName != "TABLE") dom = dom.parentNode;
- if (!dom) return;
+ if (!dom) return false;
updateColumnsOnResize(table, dom.firstChild, dom, defaultCellMinWidth, col, width);
+ return true;
}
function zeroes(n) {
return Array(n).fill(0);

9
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ patchedDependencies:
'@blocknote/core@0.46.2':
hash: 9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec
path: patches/@blocknote__core@0.46.2.patch
prosemirror-tables@1.8.5:
hash: cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b
path: patches/prosemirror-tables@1.8.5.patch
importers:
@@ -4491,7 +4494,7 @@ snapshots:
prosemirror-highlight: 0.13.1(@shikijs/types@3.22.0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.11.0)(prosemirror-view@1.41.6)
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b)
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.6
rehype-format: 5.0.1
@@ -6245,7 +6248,7 @@ snapshots:
prosemirror-schema-basic: 1.2.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b)
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.6
@@ -8099,7 +8102,7 @@ snapshots:
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.6
prosemirror-tables@1.8.5:
prosemirror-tables@1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b):
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4

View File

@@ -6,3 +6,4 @@ ignoredBuiltDependencies:
patchedDependencies:
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch
prosemirror-tables@1.8.5: patches/prosemirror-tables@1.8.5.patch

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -47,6 +47,9 @@ export function CommitDialog({ open, modifiedCount, suggestedMessage, onCommit,
{modifiedCount} file{modifiedCount !== 1 ? 's' : ''} changed
</Badge>
</div>
<DialogDescription className="sr-only">
Review changed files and enter a commit message before committing and pushing.
</DialogDescription>
</DialogHeader>
<textarea
ref={inputRef}

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
@@ -52,6 +52,9 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
<DialogContent showCloseButton={false} className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Create New Note</DialogTitle>
<DialogDescription className="sr-only">
Enter a title and choose a type for the new note.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">

View File

@@ -0,0 +1,122 @@
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand } from './testBridge'
let tempVaultDir: string
function trackUnexpectedErrors(page: Page): string[] {
const errors: string[] = []
page.on('pageerror', (error) => {
errors.push(error.message)
})
page.on('console', (message) => {
if (message.type() !== 'error') return
const text = message.text()
if (text.includes('ws://localhost:9711')) return
errors.push(text)
})
return errors
}
async function createUntitledNote(page: Page): Promise<void> {
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}), {
timeout: 5_000,
}).toBe(true)
}
async function insertTable(page: Page): Promise<void> {
const editorSurface = page.locator('.bn-editor')
await expect(editorSurface).toBeVisible({ timeout: 5_000 })
await editorSurface.click()
await page.keyboard.type('/table')
const tableCommand = page.getByText('Table with editable cells', { exact: true })
await expect(tableCommand).toBeVisible({ timeout: 5_000 })
await tableCommand.click()
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
}
async function dragFirstColumn(page: Page, deltaX: number): Promise<void> {
const firstCell = page.locator('table td, table th').first()
await expect(firstCell).toBeVisible({ timeout: 5_000 })
const box = await firstCell.boundingBox()
if (!box) throw new Error('Could not measure first table cell')
const handleX = box.x + box.width - 1
const handleY = box.y + (box.height / 2)
await page.mouse.move(handleX, handleY)
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
await page.mouse.down()
await page.mouse.move(handleX + deltaX, handleY, { steps: 8 })
await page.mouse.up()
}
test.describe('table resize crash fix', () => {
test.beforeEach((_, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('resizing a table column keeps the editor stable and changes the cell width', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await insertTable(page)
const firstCell = page.locator('table td, table th').first()
const before = await firstCell.boundingBox()
if (!before) throw new Error('Could not measure first table cell before resize')
await dragFirstColumn(page, 80)
const after = await firstCell.boundingBox()
if (!after) throw new Error('Could not measure first table cell after resize')
expect(after.width).toBeGreaterThan(before.width)
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
test('unmounting the editor mid-resize does not surface stale ProseMirror view errors', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await insertTable(page)
const firstCell = page.locator('table td, table th').first()
await expect(firstCell).toBeVisible({ timeout: 5_000 })
const box = await firstCell.boundingBox()
if (!box) throw new Error('Could not measure first table cell before resize')
const handleX = box.x + box.width - 1
const handleY = box.y + (box.height / 2)
await page.mouse.move(handleX, handleY)
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
await page.mouse.down()
await page.keyboard.press('Meta+Backslash')
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await page.mouse.move(handleX + 80, handleY, { steps: 8 })
await page.mouse.up()
expect(errors).toEqual([])
})
})