From 908994c25354fc4a502ea3337ea9c915f558df19 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 27 May 2026 18:32:18 +0200 Subject: [PATCH] fix: repair malformed nested editor blocks --- src/components/SingleEditorView.test.tsx | 11 +- src/hooks/editorBlockRepair.test.ts | 12 +- src/hooks/editorBlockRepair.ts | 24 ++- src/hooks/useEditorTabSwap.test.ts | 15 +- tests/smoke/mixed-rich-text-keypress.spec.ts | 164 +++++++++++++------ 5 files changed, 151 insertions(+), 75 deletions(-) diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index d22203c9..fd3f34c5 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -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 { diff --git a/src/hooks/editorBlockRepair.test.ts b/src/hooks/editorBlockRepair.test.ts index da1e6e39..1f3a68f3 100644 --- a/src/hooks/editorBlockRepair.test.ts +++ b/src/hooks/editorBlockRepair.test.ts @@ -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]) + }) }) diff --git a/src/hooks/editorBlockRepair.ts b/src/hooks/editorBlockRepair.ts index 5aaeb8f0..6b6f56cc 100644 --- a/src/hooks/editorBlockRepair.ts +++ b/src/hooks/editorBlockRepair.ts @@ -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 { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function isListItemBlock(block: Record): boolean { - return typeof block.type === 'string' && LIST_ITEM_TYPES.has(block.type) +function canNestChildBlocks(block: Record): boolean { + return typeof block.type === 'string' && NESTABLE_LIST_ITEM_TYPES.has(block.type) } function hasUsableBlockId(block: Record): boolean { @@ -46,20 +51,11 @@ function splitChildrenForBlock( block: Record, 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 { diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index ec7746dd..3a48863d 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -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: [], }), ]) }) diff --git a/tests/smoke/mixed-rich-text-keypress.spec.ts b/tests/smoke/mixed-rich-text-keypress.spec.ts index cf1b4e21..e5cba450 100644 --- a/tests/smoke/mixed-rich-text-keypress.spec.ts +++ b/tests/smoke/mixed-rich-text-keypress.spec.ts @@ -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)