fix: repair malformed nested editor blocks

This commit is contained in:
lucaronin
2026-05-27 18:32:18 +02:00
parent af83d270fd
commit 908994c253
5 changed files with 151 additions and 75 deletions

View File

@@ -500,11 +500,12 @@ describe('SingleEditorView', () => {
expect(editor.replaceBlocks.mock.calls[0][1]).toEqual([
expect.objectContaining({
id: expect.any(String),
children: [
expect.objectContaining({
id: expect.any(String),
}),
],
children: [],
}),
expect.objectContaining({
id: expect.any(String),
content: [{ type: 'text', text: 'Recovered child', styles: {} }],
children: [],
}),
])
} finally {

View File

@@ -15,7 +15,7 @@ function block(type: string, text: string, children: unknown[] = []) {
}
describe('repairMalformedEditorBlocks', () => {
it('promotes numbered-list children out of paragraph blocks', () => {
it('promotes all children out of paragraph blocks', () => {
const nestedParagraph = block('paragraph', 'Nested paragraph')
const nestedList = block('numberedListItem', 'Step one', [
block('numberedListItem', 'Nested step'),
@@ -26,8 +26,9 @@ describe('repairMalformedEditorBlocks', () => {
expect(repairMalformedEditorBlocks([paragraph, tail])).toEqual([
{
...paragraph,
children: [nestedParagraph],
children: [],
},
nestedParagraph,
nestedList,
tail,
])
@@ -39,4 +40,11 @@ describe('repairMalformedEditorBlocks', () => {
expect(repairMalformedEditorBlocks([parent])).toEqual([parent])
})
it('keeps nested toggle-list children under toggle-list items', () => {
const nested = block('paragraph', 'Toggle detail')
const parent = block('toggleListItem', 'Toggle summary', [nested])
expect(repairMalformedEditorBlocks([parent])).toEqual([parent])
})
})

View File

@@ -1,5 +1,10 @@
let fallbackBlockIdSequence = 0
const LIST_ITEM_TYPES = new Set(['bulletListItem', 'numberedListItem', 'checkListItem'])
const NESTABLE_LIST_ITEM_TYPES = new Set([
'bulletListItem',
'numberedListItem',
'checkListItem',
'toggleListItem',
])
type RepairResult = {
blocks: unknown[]
@@ -25,8 +30,8 @@ function isEditorBlockRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isListItemBlock(block: Record<string, unknown>): boolean {
return typeof block.type === 'string' && LIST_ITEM_TYPES.has(block.type)
function canNestChildBlocks(block: Record<string, unknown>): boolean {
return typeof block.type === 'string' && NESTABLE_LIST_ITEM_TYPES.has(block.type)
}
function hasUsableBlockId(block: Record<string, unknown>): boolean {
@@ -46,20 +51,11 @@ function splitChildrenForBlock(
block: Record<string, unknown>,
children: unknown[],
): { safeChildren: unknown[], promotedChildren: unknown[] } {
if (isListItemBlock(block)) {
if (canNestChildBlocks(block)) {
return { safeChildren: children, promotedChildren: [] }
}
const safeChildren: unknown[] = []
const promotedChildren: unknown[] = []
for (const child of children) {
if (isEditorBlockRecord(child) && isListItemBlock(child)) {
promotedChildren.push(child)
} else {
safeChildren.push(child)
}
}
return { safeChildren, promotedChildren }
return { safeChildren: [], promotedChildren: children }
}
function repairBlockList(blocks: unknown[]): RepairResult {

View File

@@ -558,13 +558,14 @@ describe('useEditorTabSwap raw mode sync', () => {
expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
expect.objectContaining({
id: expect.any(String),
children: [
expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
expect.objectContaining({
id: expect.any(String),
content: [{ type: 'text', text: 'Child', styles: {} }],
}),
],
content: [{ type: 'text', text: 'Parent', styles: {} }],
children: [],
}),
expect.objectContaining({ id: expect.any(String), type: 'paragraph', content: [], children: [] }),
expect.objectContaining({
id: expect.any(String),
content: [{ type: 'text', text: 'Child', styles: {} }],
children: [],
}),
])
})

View File

@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test'
import { test, expect, type Locator, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette } from './helpers'
@@ -51,6 +51,24 @@ Procedures are long-running processes tied to a [[responsibility|Responsibility]
Plain follow-up paragraph after the procedure checklist.
`
const NESTED_PROCEDURE_NOTE_CONTENT = `---
title: Note B
type: Procedure
status: Active
---
# Note B
Procedures are long-running processes tied to a responsibility.
- Prepare release
1. Validate nested numbered content
2. Notify the owner
- Record outcome
Plain follow-up paragraph after the nested procedure checklist.
`
let tempVaultDir: string
test.beforeEach(async ({ page }, testInfo) => {
@@ -141,6 +159,99 @@ function expectNoBlockContainerCrash(messages: string[]) {
))).toEqual([])
}
async function loadRawContentInRichEditor(page: Page, content: string) {
await openNote(page, 'Note B')
await toggleRawMode(page, '.cm-content')
await setRawEditorContent(page, content)
await page.waitForTimeout(700)
await toggleRawMode(page, '.bn-editor')
}
async function appendMarkerToBlock(page: Page, block: Locator, marker: string) {
await expect(block).toBeVisible({ timeout: 5_000 })
await block.click()
await page.keyboard.press('End')
await page.keyboard.insertText(` ${marker}`)
await page.waitForTimeout(900)
}
async function reopenNoteB(page: Page) {
await openNote(page, 'Note C')
await openNote(page, 'Note B')
}
async function expectListContentEditableAfterReopen(page: Page, options: {
content: string
markerPrefix: string
primary: {
selector: string
text: string
}
secondary: {
selector: string
text: string
}
}) {
const crashSignals = collectEditorCrashSignals(page)
const markerId = Date.now()
const primaryMarker = `${options.markerPrefix}-primary-${markerId}`
const secondaryMarker = `${options.markerPrefix}-secondary-${markerId}`
const reentryMarker = `${options.markerPrefix}-reentry-${markerId}`
await loadRawContentInRichEditor(page, options.content)
const primaryBlock = page.locator(options.primary.selector, {
hasText: options.primary.text,
}).first()
await appendMarkerToBlock(page, primaryBlock, primaryMarker)
const secondaryBlock = page.locator(options.secondary.selector, {
hasText: options.secondary.text,
}).first()
await appendMarkerToBlock(page, secondaryBlock, secondaryMarker)
await reopenNoteB(page)
await appendMarkerToBlock(page, primaryBlock, reentryMarker)
expectNoBlockContainerCrash(crashSignals)
await toggleRawMode(page, '.cm-content')
const raw = await getRawEditorContent(page)
expect(raw).toContain(primaryMarker)
expect(raw).toContain(secondaryMarker)
expect(raw).toContain(reentryMarker)
expectNoBlockContainerCrash(crashSignals)
}
const listReopenScenarios = [
{
name: 'numbered list content stays editable after autosave and re-entry',
content: NUMBERED_REENTRY_CONTENT,
markerPrefix: 'numbered',
primary: {
selector: '.bn-block-content[data-content-type="numberedListItem"]',
text: 'First numbered item',
},
secondary: {
selector: '.bn-block-content',
text: 'Plain paragraph after the numbered list.',
},
},
{
name: 'nested procedure list content stays editable after save and reopen',
content: NESTED_PROCEDURE_NOTE_CONTENT,
markerPrefix: 'nested',
primary: {
selector: '.bn-block-content[data-content-type="numberedListItem"]',
text: 'Validate nested numbered content',
},
secondary: {
selector: '.bn-block-content[data-content-type="bulletListItem"]',
text: 'Record outcome',
},
},
] as const
test('mixed rich-text blocks with Korean list content stay editable after action clicks', async ({ page }) => {
const crashSignals = collectEditorCrashSignals(page)
@@ -174,52 +285,11 @@ test('mixed rich-text blocks with Korean list content stay editable after action
expectNoBlockContainerCrash(crashSignals)
})
test('numbered list content stays editable after autosave and re-entry', async ({ page }) => {
const crashSignals = collectEditorCrashSignals(page)
const markerId = Date.now()
const listMarker = `list-token-${markerId}`
const paragraphMarker = `paragraph-token-${markerId}`
const reentryMarker = `reentry-token-${markerId}`
await openNote(page, 'Note B')
await toggleRawMode(page, '.cm-content')
await setRawEditorContent(page, NUMBERED_REENTRY_CONTENT)
await page.waitForTimeout(700)
await toggleRawMode(page, '.bn-editor')
const numberedItem = page.locator('.bn-block-content[data-content-type="numberedListItem"]', {
hasText: 'First numbered item',
}).first()
await expect(numberedItem).toBeVisible({ timeout: 5_000 })
await numberedItem.click()
await page.keyboard.press('End')
await page.keyboard.insertText(` ${listMarker}`)
await page.waitForTimeout(900)
const paragraph = page.locator('.bn-block-content', {
hasText: 'Plain paragraph after the numbered list.',
}).first()
await paragraph.click()
await page.keyboard.press('End')
await page.keyboard.insertText(` ${paragraphMarker}`)
await page.waitForTimeout(900)
await openNote(page, 'Note C')
await openNote(page, 'Note B')
await numberedItem.click()
await page.keyboard.press('End')
await page.keyboard.insertText(` ${reentryMarker}`)
await page.waitForTimeout(700)
expectNoBlockContainerCrash(crashSignals)
await toggleRawMode(page, '.cm-content')
const raw = await getRawEditorContent(page)
expect(raw).toContain(listMarker)
expect(raw).toContain(paragraphMarker)
expect(raw).toContain(reentryMarker)
expectNoBlockContainerCrash(crashSignals)
})
for (const scenario of listReopenScenarios) {
test(scenario.name, async ({ page }) => {
await expectListContentEditableAfterReopen(page, scenario)
})
}
test('Procedure prose and adjacent bullet-list content stay editable through keydown edits', async ({ page }) => {
const crashSignals = collectEditorCrashSignals(page)