fix(front): prevent timeline "Invalid configuration" on update events without a diff (#21460)

## Fixes #20597

### Problem
A person's (or any record's) timeline renders the whole widget as
**"Invalid configuration"** when it contains an `*.updated` event
without a usable `properties.diff`.

The error-boundary fallback (`PageLayoutWidgetInvalidConfigDisplay`) is
triggered because `EventRowMainObjectUpdated` **throws** during render:

```ts
const diff = event.properties?.diff;       // can be undefined
const diffEntries = Object.entries(diff);  // throws TypeError when undefined
if (diffEntries.length === 0) {
  throw new Error('Cannot render update description without changes');
}
```

`filterOutInvalidTimelineActivities` only validates activities that
**already carry** a diff (`canSkipValidation = !diff`), so a main-object
`*.updated` event with a missing diff passes straight through to this
renderer and crashes it. A single malformed row takes down the entire
timeline.

### Fix
Render nothing instead of throwing when an update event has no changes
to show. This mirrors the sibling `EventRowMainObject` default branch
(which returns `null`) and the filter's own behaviour of dropping empty
diffs, and keeps one bad row from crashing the whole widget.

The fix is intentionally kept in the renderer rather than the filter:
the filter cannot distinguish a diff-less main-object update (must be
dropped) from a diff-less `linked-task`/`linked-note` update
(legitimately has `properties: {}` and renders fine via
`EventRowActivity`) without duplicating routing logic.

### Test
Added `EventRowMainObjectUpdated.test.tsx` — a regression test asserting
the component renders nothing (no throw) for both a missing-diff and an
empty-diff update event.
This commit is contained in:
Charles Bochet
2026-06-12 18:58:05 +02:00
committed by GitHub
parent 577b22df46
commit 247e422eac
3 changed files with 147 additions and 141 deletions
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, MoreThan } from 'typeorm';
import { objectRecordDiffMerge } from 'src/engine/core-modules/event-emitter/utils/object-record-diff-merge';
@@ -38,21 +38,23 @@ export class TimelineActivityRepository {
payloads,
});
const payloadsWithDiff = payloads
.filter(({ properties }) => {
const isDiffEmpty =
properties.diff !== null &&
properties.diff &&
Object.keys(properties.diff).length === 0;
const payloadsToUpsert = payloads.flatMap(
({ name, properties, ...rest }) => {
const [objectName, action] = name.split('.');
const { diff } = properties;
const hasDiff = isDefined(diff) && Object.keys(diff).length > 0;
return !isDiffEmpty;
})
.map(({ properties, ...rest }) => ({
...rest,
properties: isDefined(properties.diff)
? { diff: properties.diff }
: {},
}));
if (objectName.startsWith('linked-')) {
return [{ ...rest, name, properties: hasDiff ? { diff } : {} }];
}
if (action === 'updated') {
return hasDiff ? [{ ...rest, name, properties: { diff } }] : [];
}
return [{ ...rest, name, properties: {} }];
},
);
const payloadsToInsert: TimelineActivityPayloadWorkspaceIdAndObjectSingularName['payloads'] =
[];
@@ -60,7 +62,7 @@ export class TimelineActivityRepository {
const timelineActivityPropertyName =
await this.getTimelineActivityPropertyName(objectSingularName);
for (const payload of payloadsWithDiff) {
for (const payload of payloadsToUpsert) {
const recentTimelineActivity = recentTimelineActivities.find(
(timelineActivity) =>
timelineActivity[timelineActivityPropertyName] ===