[lexical-playground] Bug Fix: importing an unparseable data-lexical-datetime no longer breaks serialization - #9047
Conversation
…atetime no longer breaks serialization
## Description
`DateTimeRule` builds a node from the attribute without checking that it parsed:
```ts
const DateTimeRule = defineImportRule({
$import: (ctx, el) => {
const dateTimeValue = el.getAttribute('data-lexical-datetime')!;
const node = $createDateTimeNode(new Date(Date.parse(dateTimeValue))); // NaN -> Invalid Date
const [firstChild] = ctx.$importChildren(el);
...
return [node];
},
match: sel.tag('span').attr('data-lexical-datetime', true),
```
`GoogleDocsDateRule`, twenty lines below in the same file and matching the same kind of pasted markup, does exactly the check that is missing:
```ts
if (isNaN(parsedDate)) {
return $next();
}
```
When `Date.parse` fails, the rule still claims the element and returns the node, so `$next()` is never reached and the element's own text is discarded. The node then holds an `Invalid Date`, and its state config unparses with `toISOString()`:
```ts
const dateTimeState = createState('dateTime', {
parse: v => new Date(v as string),
unparse: v => v.toISOString(),
});
```
`Date.prototype.toISOString` throws `RangeError: Invalid time value` on an Invalid Date, and `LexicalNodeState.toJSON` calls `unparse` for any value that differs from the default — which an `Invalid Date` object always does.
Measured on `upstream/main`, pasting `<span data-lexical-datetime="not a date">some text</span>`:
```
INVALID text= "Invalid Date NaN:NaN" toJSON THREW: RangeError Invalid time value
GDOCS-INVALID text= "gd text" toJSON: OK
VALID text= "Thu May 28 2026 17:00" toJSON: OK
```
So one bad attribute in pasted HTML both replaces the visible text with `Invalid Date NaN:NaN` and makes the whole editor state unserializable: every later `editorState.toJSON()` throws, which is what the playground's autosave, the shareable-document URL, collab and the debug tree view all call. The document cannot be saved again until that node is deleted. The sibling rule shows the intended handling — fall through and keep the element as ordinary content.
This adds the same guard. `$next` was already part of the rule signature, it simply was not used here. No API or serialization change.
## Test plan
New unit test `packages/lexical-playground/__tests__/unit/DateTimeImport.test.ts`. Two of the three cases are controls that pass before and after: a parseable date must still import, and the Google Docs rule's existing behaviour is asserted alongside so the two rules are pinned to the same contract.
### Before
```
❯ packages/lexical-playground/__tests__/unit/DateTimeImport.test.ts (3 tests | 1 failed)
× keeps the element content when the date cannot be parsed
AssertionError: expected true to be false // Object.is equality
✓ still imports a parseable date
✓ matches how the Google Docs rule already handles an unparseable date
Test Files 1 failed (1)
Tests 1 failed | 2 passed (3)
```
### After
```
Test Files 1 passed (1)
Tests 3 passed (3)
```
Package suite is unchanged:
```
$ npx vitest run packages/lexical-playground
Test Files 19 passed (19)
Tests 277 passed (277)
```
|
@LeSingh1 is attempting to deploy a commit to the Meta Open Source Team on Vercel. A member of the Team first needs to authorize it. |
|
Ran the new test on |
| }; | ||
|
|
||
| function importHtml(html: string): ImportResult { | ||
| using editor: LexicalEditor & Disposable = buildEditorFromExtensions( |
There was a problem hiding this comment.
The annotation and type import are redundant — buildEditorFromExtensions returns LexicalEditorWithDispose which already satisfies Disposable. The rest of the playground tests (e.g. CardNode.test.ts) use bare using editor = buildEditorFromExtensions(...).
| import {PlaygroundImportExtension} from '../../src/nodes/PlaygroundImportExtension'; | ||
| import {DateTimeExtension} from '../../src/plugins/DateTimeExtension'; | ||
|
|
||
| const DateTimeImportTestExtension = /* @__PURE__ */ defineExtension({ |
There was a problem hiding this comment.
The /* @__PURE__ */ annotation on a test-only constant has no effect (tests aren't tree-shaken). The nearby playground tests drop it for defineExtension calls — this one stands out.
| ); | ||
|
|
||
| expect(result.hasDateTimeNode).toBe(true); | ||
| expect(result.serializes).toBe(true); |
There was a problem hiding this comment.
The other two cases assert result.text; this one doesn't. A regression that creates an empty DateTimeNode (dropping "May 29") would still pass. Consider adding expect(result.text).toBe('May 29').
Description
DateTimeRulebuilds a node from the attribute without checking that it parsed:GoogleDocsDateRule, twenty lines below in the same file and matching the same kind of pasted markup, does exactly the check that is missing:When
Date.parsefails, the rule still claims the element and returns the node, so$next()is never reached and the element's own text is discarded. The node then holds anInvalid Date, and its state config unparses withtoISOString():Date.prototype.toISOStringthrowsRangeError: Invalid time valueon an Invalid Date, andLexicalNodeState.toJSONcallsunparsefor any value that differs from the default — which anInvalid Dateobject always does.Measured on
upstream/main, pasting<span data-lexical-datetime="not a date">some text</span>:So one bad attribute in pasted HTML both replaces the visible text with
Invalid Date NaN:NaNand makes the whole editor state unserializable: every latereditorState.toJSON()throws, which is what the playground's autosave, the shareable-document URL, collab and the debug tree view all call. The document cannot be saved again until that node is deleted. The sibling rule shows the intended handling — fall through and keep the element as ordinary content.This adds the same guard.
$nextwas already part of the rule signature, it simply was not used here. No API or serialization change.Test plan
New unit test
packages/lexical-playground/__tests__/unit/DateTimeImport.test.ts. Two of the three cases are controls that pass before and after: a parseable date must still import, and the Google Docs rule's existing behaviour is asserted alongside so the two rules are pinned to the same contract.Before
After
Package suite is unchanged: