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).
This commit is contained in:
+44
@@ -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;
|
||||
|
||||
+6
-4
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user