Compare commits

...
Author SHA1 Message Date
sonarly-bot 5d4f184a07 fix(twenty-front): guard malformed blocknote editor content
https://sonarly.com/issue/41010?type=bug

An unhandled front-end exception crashes the Notes editor view for affected records/users. The crash happens inside BlockNote side-menu hover handling when it reads `id` from an undefined block.

Fix: I hardened BlockNote initial-content parsing to prevent malformed block arrays from reaching the editor runtime:

1) `parseInitialBlocknote` now:
- Parses JSON as `unknown` instead of trusting `PartialBlock[]`
- Validates each block is an object with a non-empty `type`
- Recursively sanitizes `children`, dropping invalid nested blocks
- Returns `undefined` when the sanitized result is empty

This prevents `undefined` / malformed blocks from entering BlockNote state and triggering `block.id` dereference crashes in hover/side-menu paths.

2) I expanded parser unit tests to cover:
- Mixed valid + invalid top-level blocks
- Arrays where all blocks are invalid
- Nested invalid `children` sanitization

Authored by Sonarly by autonomous analysis (run 46745).
2026-05-27 09:44:18 +00:00
3 changed files with 84 additions and 7 deletions
@@ -38,6 +38,50 @@ describe('parseInitialBlocknote', () => {
expect(parseInitialBlocknote('{"key": "value"}')).toBeUndefined();
});
it('should filter invalid blocks from the parsed content', () => {
const input = JSON.stringify([
{ type: 'paragraph', content: 'valid block' },
null,
{ content: 'missing type' },
]);
expect(parseInitialBlocknote(input)).toEqual([
{ type: 'paragraph', content: 'valid block' },
]);
});
it('should return undefined when all parsed blocks are invalid', () => {
const input = JSON.stringify([null, { content: 'missing type' }]);
expect(parseInitialBlocknote(input)).toBeUndefined();
});
it('should sanitize invalid nested children', () => {
const input = JSON.stringify([
{
type: 'paragraph',
content: 'parent',
children: [
{ type: 'paragraph', content: 'valid child' },
{ content: 'invalid child' },
],
},
]);
expect(parseInitialBlocknote(input)).toEqual([
{
type: 'paragraph',
content: 'parent',
children: [
{
type: 'paragraph',
content: 'valid child',
},
],
},
]);
});
it('should use custom log context when parsing fails', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
parseInitialBlocknote('invalid', 'Custom context');
@@ -1,12 +1,35 @@
import type { PartialBlock } from '@blocknote/core';
import { isArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
const isObjectRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null;
};
const sanitizePartialBlock = (block: unknown): PartialBlock | undefined => {
if (!isObjectRecord(block) || !isNonEmptyString(block.type)) {
return undefined;
}
const sanitizedBlock = {
...block,
} as PartialBlock;
if (isArray(block.children)) {
sanitizedBlock.children = block.children
.map(sanitizePartialBlock)
.filter(isDefined);
}
return sanitizedBlock;
};
export const parseInitialBlocknote = (
blocknote?: string | null,
logContext?: string,
): PartialBlock[] | undefined => {
if (isNonEmptyString(blocknote) && blocknote !== '{}') {
let parsedBody: PartialBlock[] | undefined = undefined;
let parsedBody: unknown = undefined;
// TODO: Remove this once we have removed the old rich text
try {
@@ -18,11 +41,19 @@ export const parseInitialBlocknote = (
console.warn(blocknote);
}
if (!isArray(parsedBody) || parsedBody.length === 0) {
if (!isArray(parsedBody)) {
return undefined;
}
return parsedBody;
const sanitizedBody = parsedBody
.map(sanitizePartialBlock)
.filter(isDefined);
if (sanitizedBody.length === 0) {
return undefined;
}
return sanitizedBody;
}
return undefined;
@@ -44,12 +44,14 @@ export const PromiseRejectionEffect = () => {
error?.networkError?.name === 'AbortError' ||
error?.name === 'AbortError';
if (!isAbortError) {
enqueueErrorSnackBar(
error instanceof Error ? { message: error.message } : {},
);
if (isAbortError) {
return;
}
enqueueErrorSnackBar(
error instanceof Error ? { message: error.message } : {},
);
try {
const { captureException } = await import('@sentry/react');
captureException(error, (scope) => {