Repository navigation
Pass the raw-JSON editor to custom collection nodes as originalNode - #414
Conversation
A custom collection component with `showOnEdit` owns its row's edit session, so JER never rendered its raw-JSON textarea for that row. A component wanting "edit as JSON" beside its own editor had to rebuild the textarea, and couldn't fully: on displace, JER commits the row's own edit buffer, which the component never wrote to. With `passOriginalNode: true`, the component now receives JER's own text-edit block as `originalNode` while editing. It is the same element standard editing renders, bound to the row's buffer and `handleEdit`, so parsing, INVALID_JSON, keyboard controls, a host `TextEditor` and commit-on-displace all work unchanged. No new props, types or exports. The block moves unchanged into a `getTextEdit()` getter, called in place of the child rows for standard editing and at the component site (not the wrapper, which would duplicate the textarea ref). The Playlist demo example gains an "Edit as JSON" fallback. Closes #411 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bundle size impact
|
| Format | Base raw | PR raw | Δ raw | Base gzip | PR gzip | Δ gzip |
|---|---|---|---|---|---|---|
| esm | 58.79 KB | 59.02 KB | 🔺 +232 B (+0.39%) | 20.82 KB | 20.89 KB | 🔺 +74 B (+0.35%) |
| cjs | 60.23 KB | 60.46 KB | 🔺 +238 B (+0.39%) | 20.83 KB | 20.92 KB | 🔺 +88 B (+0.41%) |
Measured from build/index.{cjs,esm}.js. Gzip at level 9.
CarlosNZ
left a comment
There was a problem hiding this comment.
This covers what #411 asked for, and it fits fig-tree-editor-react's v3 plan. Keeping ✓/✗ in the element is right: a component that shows originalNode has no other way to close the session, and the ✓ attaches editConfirmRef, which editorRef.confirm() clicks. I'd merge after the first fix below. The others are smaller.
Fix before merging
1. Displacement commits an edit even when the buffer was never touched (CollectionNode.tsx#L271-L280)
commitOp runs handleEdit, which always parses the buffer into a new object. buildCommit treats an edit as a no-op only when the value is the same object (isNoOp: currentValue === newValue, JsonEditor.tsx#L302). So displacing a session nobody typed into still counts as a real edit. That happens in a showOnEdit component's own mode, like the Playlist's Reorder…, and also when JER's raw JSON is opened and left untouched.
Checked with a jest test: open the component's own mode, then double-click another row. onUpdate and setData each fire once with an unchanged value. The host gets an extra onUpdate, an undo step and a re-render of every row. Values that don't survive jsonStringify/jsonParse are quietly rewritten.
This behaviour predates the PR, since setIsEditing already registered this commitOp. But this PR is the natural place to fix it, and it's on fig-tree-editor's path: every displaced toolbar session would reach the host as an edit.
Suggested fix: when the buffer is untouched, commit data itself, so the existing no-op path takes over. That path closes the session, fires the commit event and still runs onCommit.
const value =
stringifiedValue === null ? data : (jsonParse(textToParse) as CollectionData)(with the try/catch around the parse only). Worth adding a test that displaces an untouched session and asserts that onUpdate isn't called.
Worth doing
2. The Playlist demo never resets jsonMode (Example.tsx#L96)
A session opened any way other than the two header buttons reuses whichever mode was used last. The ways are JER's edit-tools ✎ and editorRef.startEdit. For example, "Edit as JSON" then ✓, and the ✎ later opens JSON; after a Reorder session it opens the toolbar. Consumers will copy this example, so it should reset the mode when the session ends, to a fixed default. The README line "Pick the mode before calling setIsEditing" could say the same, since the openers above don't go through the component.
3. A behaviour change in a patch release (CollectionNode.tsx#L534-L538)
Collection components used to ignore passOriginalNode. A definition that already sets it with showOnEdit and renders originalNode ?? children now shows the textarea instead of the rows while editing. test/helpers/renderSpy.tsx follows exactly that pattern. This is probably rare, but the CHANGELOG entry should mention it.
Minor
- Tab in a component's own textarea. The Tab crash listed under "Not in this PR" gets a second form. A component that wires
onKeyDownto its own textarea and also mountsoriginalNodehas Tab inserted into the JSON textarea, at its old caret position, instead of crashing. It belongs in the same follow-up. originalNodeKey. ThepassOriginalNodecomment in types.ts#L602-L606 mentionsoriginalNode/originalNodeKey, but collection components never receiveoriginalNodeKey. It's worth saying so.- Rendering
originalNodeonce. The README could say to render it at most once. The element carries a fixed textareaid/namefrompathString, the node'stextAreaRefand the editor-wideeditConfirmRef, so a second copy would have duplicate IDs and a contested ref. - Demo ternary.
isEditing && jsonMode ? null : isEditing ? … : …(Example.tsx#L148) reads more easily asisEditing ? (jsonMode ? null : toolbar) : viewButtons.
🤖 Generated with Claude Code
`handleEdit` always parsed the raw-JSON buffer, building a new object even when nobody had typed. The commit engine treats an edit as a no-op only when the value is the same object, so closing an untouched collection session (with ✓, or by opening another node) reached the host as a real edit: an `onUpdate` and `setData` with an unchanged value, an undo step, a whole-tree re-render, and a silent round-trip through `jsonStringify`/`jsonParse`. This hit a custom `showOnEdit` collection's own session too, since `setIsEditing` registers the same `commitOp`, so every displaced toolbar session was reported as an edit. An untouched buffer now commits `data` itself, which takes the engine's existing no-op path: the session closes and `commit*` and `onCommit` still fire, but `onUpdate` and the apply don't. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A custom collection component receives `onKeyDown: onKeyDownEdit`, whose Tab branch always inserted into the node's `textAreaRef`. Wired to an input of the component's own, Tab either threw (the ref is unattached unless `originalNode` is mounted) or, with `originalNode` mounted beside it, inserted a tab into the JSON textarea at its old caret, touching that buffer so a later displace would commit it. The Tab insertion now runs only when the event comes from the node's own textarea; anywhere else the key falls through to `handleKeyboard`, which has no Tab binding here, so the browser's default applies. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Playlist demo: reset `jsonMode` whenever the session ends, since JER can open it without the header buttons (its edit button, `editorRef.startEdit`); simplify the header ternary. - README: reset the mode when `isEditing` goes false; collections get no `originalNodeKey`; render `originalNode` at most once. - types.ts: note that collections get no `originalNodeKey`. - CHANGELOG: call out the visible change for a collection definition that already sets `passOriginalNode` with `showOnEdit`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks. All seven findings are addressed, in three commits: b32837f, 81b8930 and a9a0f91. 1. Displacement commits an untouched edit. Fixed in b32837f, as you suggested. When the buffer is untouched ( Three regression tests, each seen failing first:
Each asserts that neither 2. Playlist demo never resets
3. Behaviour change in a patch release. Added to the #411 CHANGELOG bullet in a9a0f91: a collection definition that already sets Minor: Tab in a component's own textarea. Folded into this PR, in 81b8930, rather than a follow-up. The Tab insertion in Minor: Minor: render Minor: demo ternary. Now Full suite green (34 suites), lint and 🤖 Generated with Claude Code |
|
Rechecked at a9a0f91: all seven fixes hold up, the suite is green and A sync
|
On a synchronous `onUpdate` reject, the engine closes the session without running `cancelOp` or `onCommit`, so `clearEditBuffer` never fired and the rejected text stayed in the buffer. The untouched-buffer check then read it as a real edit: a later session on the node (such as a custom component's own mode, with the stale text hidden behind it) re-submitted the rejected JSON on displace, `onUpdate` rejected it again, and the switch to the clicked node was blocked. A plain collection also reopened on the rejected text rather than its data. A rejected or cancelled outcome now clears the buffer, as a value node's `settleEdit` reverts its own, skipped when the user has since reopened the node so a late async settlement can't clobber a new edit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Playlist demo: reset `jsonMode` whenever the session ends, since JER can open it without the header buttons (its edit button, `editorRef.startEdit`); simplify the header ternary. - README: reset the mode when `isEditing` goes false; collections get no `originalNodeKey`; render `originalNode` at most once. - types.ts: note that collections get no `originalNodeKey`. - CHANGELOG: call out the visible change for a collection definition that already sets `passOriginalNode` with `showOnEdit`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Closes #411
What it does
With
passOriginalNode: true, a custom collection component withshowOnEditreceives JER's built-in raw-JSON editor asoriginalNodewhile editing (undefinedotherwise). The component can render it in place ofchildrenas an "edit as JSON" fallback beside its own editor.It's the same element standard collection editing renders, bound to the row's own edit buffer and
handleEdit. So all of these work unchanged:jsonParse/jsonStringifyINVALID_JSON(localised)keyboardControls, and Tab inserting a tabTextEditorNo new props, types or exports. It reuses the existing
passOriginalNode/originalNode, which collections previously ignored.Core change (src/CollectionNode.tsx)
getTextEdit()getter. The getter is called in place of the child rows for standard editing, and at the custom component site asoriginalNode.getCustomNodeAllProps(). That function also feedswrapperComponent, and a second rendered copy would sharetextAreaRef.<>{getTextEdit()}</>rather than a bare call. With neither branch as JSX, Prettier switches the ternary out of JSX style and re-indents about 45 untouched lines of the child-rowsmap. The fragment keeps them untouched. It's a one-line swap if you'd rather take the re-indent.Also fixed (from review)
Both bugs predate this PR, but the PR makes them easier to hit.
handleEditalways parsed the buffer into a new object, and the commit engine only treats an edit as a no-op when the value is the same object. So closing an untouched collection session reached the host as a real edit: anonUpdateandsetDatawith an unchanged value, an undo step and a whole-tree re-render. That happened with ✓, and also on displace, which includes a customshowOnEditcomponent's own session (e.g. the Playlist's Reorder…). An untouched buffer now commitsdataitself, so the existing no-op path applies: the session closes andcommit*andonCommitstill fire.onKeyDownit receives to an input of its own crashed on Tab, because the textarea ref was unattached. WithoriginalNodemounted beside that input, Tab was instead inserted into the JSON textarea. Tab insertion now runs only when the event comes from the node's own textarea. Anywhere else, the browser's default applies.Tests
Six new tests in
test/customNode.test.tsx(… composes the raw-JSON editor via originalNode (#411)):INVALID_JSONand the editor stays openTextEditoris usedoriginalNodewithoutpassOriginalNodeFive of them failed before the change for the expected reason (no editor passed).
Regression tests for the review fixes, each watched failing first:
onUpdateorsetDataonUpdateorsetDataoriginalNodemounted: no crash, and the JSON buffer is untouchedFull suite: 34 suites green.
pnpm lintandtscare clean.The render-scope sentinel's doc comment is updated too. It renders
originalNode ?? children, so a collection sentinel in edit mode now shows the JSON editor, which is what a default collection does.Demo and docs
editorRef.startEdit). I checked it in Chrome:originalNodeKeyfor collections, and renderingoriginalNodeat most once), plus thepassOriginalNodeline in the definition block.types.tscomment and the Playlist registry blurb.## 2.0.1:passOriginalNodewithshowOnEditand rendersoriginalNode ?? childrenNot in this PR
🤖 Generated with Claude Code