Fix: Ensure block IDs exist before passing content to BlockNote editor

https://sonarly.com/issue/24449?type=bug

When a user navigates to `/objects/notes` and opens a note whose stored `bodyV2.blocknote` JSON contains blocks without `id` fields, BlockNote 0.47.x throws "Error: Block doesn't have id" during ProseMirror EditorView initialization. The error originates in BlockNote's internal node view factory (`yv` function) which reads `node.attrs.id` from each `blockContainer` ProseMirror node and throws if it's falsy. The `parseInitialBlocknote` utility parses stored JSON into `PartialBlock[]` without ensuring blocks have IDs, and while `useCreateBlockNote` is designed to auto-generate IDs for PartialBlocks, certain block structures (nested children, specific block types, or data written under the previous BlockNote 0.31.x format) fail to receive IDs before the ProseMirror view mounts.
This commit is contained in:
Sonarly Claude Code 2026-04-13 10:58:22 +00:00
parent 21142d98fe
commit e71539c0ba
4 changed files with 118 additions and 3 deletions

View file

@ -0,0 +1,63 @@
import type { PartialBlock } from '@blocknote/core';
import { ensureBlockIds } from '@/blocknote-editor/utils/ensureBlockIds';
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
describe('ensureBlockIds', () => {
it('should add id to a block without one', () => {
const blocks: PartialBlock[] = [{ type: 'paragraph', content: 'hello' }];
const result = ensureBlockIds(blocks);
expect(result[0].id).toMatch(UUID_REGEX);
});
it('should preserve existing valid id', () => {
const blocks: PartialBlock[] = [
{ id: 'existing-id', type: 'paragraph', content: 'hello' },
];
const result = ensureBlockIds(blocks);
expect(result[0].id).toBe('existing-id');
});
it('should replace empty string id', () => {
const blocks: PartialBlock[] = [
{ id: '', type: 'paragraph', content: 'hello' },
];
const result = ensureBlockIds(blocks);
expect(result[0].id).toMatch(UUID_REGEX);
});
it('should handle nested children blocks', () => {
const blocks: PartialBlock[] = [
{
type: 'paragraph',
children: [{ type: 'paragraph', content: 'nested' }],
},
];
const result = ensureBlockIds(blocks);
expect(result[0].id).toMatch(UUID_REGEX);
expect(result[0].children?.[0]?.id).toMatch(UUID_REGEX);
});
it('should not mutate the original blocks', () => {
const blocks: PartialBlock[] = [{ type: 'paragraph', content: 'hello' }];
ensureBlockIds(blocks);
expect(blocks[0].id).toBeUndefined();
});
it('should handle empty children array', () => {
const blocks: PartialBlock[] = [
{ type: 'paragraph', children: [], content: 'hello' },
];
const result = ensureBlockIds(blocks);
expect(result[0].id).toMatch(UUID_REGEX);
expect(result[0].children).toEqual([]);
});
});

View file

@ -1,10 +1,20 @@
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
describe('parseInitialBlocknote', () => {
it('should parse valid JSON array string', () => {
it('should parse valid JSON array string and ensure block ids', () => {
const input = JSON.stringify([{ type: 'paragraph', content: 'test' }]);
const result = parseInitialBlocknote(input);
expect(result).toEqual([{ type: 'paragraph', content: 'test' }]);
expect(result).toHaveLength(1);
expect(result?.[0].type).toBe('paragraph');
expect(result?.[0].id).toBeDefined();
});
it('should preserve existing block ids', () => {
const input = JSON.stringify([
{ id: 'my-id', type: 'paragraph', content: 'test' },
]);
const result = parseInitialBlocknote(input);
expect(result?.[0].id).toBe('my-id');
});
it('should return undefined for empty string', () => {

View file

@ -0,0 +1,40 @@
import type { PartialBlock } from '@blocknote/core';
import { isNonEmptyString } from '@sniptt/guards';
import { v4 } from 'uuid';
// Recursively ensures every block has a valid `id`.
// BlockNote 0.47.x throws "Block doesn't have id" during ProseMirror
// view creation if any block is missing its id attribute — even though
// PartialBlock allows omitting it.
export const ensureBlockIds = (blocks: PartialBlock[]): PartialBlock[] => {
let patchedCount = 0;
const result = blocks.map((block) => {
const patchedBlock = { ...block };
if (!isNonEmptyString(patchedBlock.id)) {
patchedBlock.id = v4();
patchedCount++;
}
if (
Array.isArray(patchedBlock.children) &&
patchedBlock.children.length > 0
) {
patchedBlock.children = ensureBlockIds(
patchedBlock.children as PartialBlock[],
);
}
return patchedBlock;
});
if (patchedCount > 0) {
// oxlint-disable-next-line no-console
console.warn(
`[BlockNote] Patched ${patchedCount} block(s) missing id attribute`,
);
}
return result;
};

View file

@ -1,6 +1,8 @@
import type { PartialBlock } from '@blocknote/core';
import { isArray, isNonEmptyString } from '@sniptt/guards';
import { ensureBlockIds } from '@/blocknote-editor/utils/ensureBlockIds';
export const parseInitialBlocknote = (
blocknote?: string | null,
logContext?: string,
@ -22,7 +24,7 @@ export const parseInitialBlocknote = (
return undefined;
}
return parsedBody;
return ensureBlockIds(parsedBody);
}
return undefined;