Skip to content

Pass the raw-JSON editor to custom collection nodes as originalNode - #414

Merged
CarlosNZ merged 6 commits into
mainfrom
411-pass-through-JSON-edditor
Sep 29, 2026
Merged

CarlosNZ merged 6 commits into
mainfrom
411-pass-through-JSON-edditor

Conversation

@CarlosNZ

@CarlosNZ CarlosNZ commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Closes #411

What it does

With passOriginalNode: true, a custom collection component with showOnEdit receives JER's built-in raw-JSON editor as originalNode while editing (undefined otherwise). The component can render it in place of children as 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 / jsonStringify
  • INVALID_JSON (localised)
  • keyboardControls, and Tab inserting a tab
  • a host TextEditor
  • the ✓/✗ buttons
  • commit on displace, which is the part a component couldn't fix from outside

No new props, types or exports. It reuses the existing passOriginalNode / originalNode, which collections previously ignored.

Core change (src/CollectionNode.tsx)

  • The text-edit block moves unchanged into a getTextEdit() getter. The getter is called in place of the child rows for standard editing, and at the custom component site as originalNode.
  • It's passed on the component only, not via getCustomNodeAllProps(). That function also feeds wrapperComponent, and a second rendered copy would share textAreaRef.
  • It's built only while editing, and only for the custom component. No node props, memo comparator or editing-store changes.
  • The else branch is written <>{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-rows map. 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.

  • An untouched collection edit is a no-op. handleEdit always 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: an onUpdate and setData with an unchanged value, an undo step and a whole-tree re-render. That happened with ✓, and also on displace, which includes a custom showOnEdit component's own session (e.g. the Playlist's Reorder…). An untouched buffer now commits data itself, so the existing no-op path applies: the session closes and commit* and onCommit still fire.
  • Tab in a component's own input. A component that wired the onKeyDown it receives to an input of its own crashed on Tab, because the textarea ref was unattached. With originalNode mounted 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)):

  • render, then ✓ commits
  • commit on displace
  • invalid JSON reports INVALID_JSON and the editor stays open
  • Tab inserts a tab
  • a host TextEditor is used
  • no originalNode without passOriginalNode

Five of them failed before the change for the expected reason (no editor passed).

Regression tests for the review fixes, each watched failing first:

  • untouched collection edit, displaced: no onUpdate or setData
  • untouched collection edit, confirmed with ✓: no onUpdate or setData
  • a component's own untouched session, displaced: nothing commits
  • Tab in a component's own textarea, with and without originalNode mounted: no crash, and the JSON buffer is untouched

Full suite: 34 suites green. pnpm lint and tsc are 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

  • Playlist example: new Edit as JSON button next to Reorder…. The mode resets to the toolbar whenever a session ends, because JER can open the session without the header buttons (its ✎ edit button, editorRef.startEdit). I checked it in Chrome:
    • typed JSON commits with ✓ and on displace
    • bad JSON shows "Invalid JSON" with the editor kept open
    • Tab inserts a tab
    • ✎ after a JSON session opens the toolbar
    • Reorder is unaffected
  • README: a bullet in "Collection nodes" (including resetting the mode, no originalNodeKey for collections, and rendering originalNode at most once), plus the passOriginalNode line in the definition block.
  • Also updated: the types.ts comment and the Playlist registry blurb.
  • CHANGELOG entries under ## 2.0.1:
    • the feature, with a note on the visible change for a collection definition that already sets passOriginalNode with showOnEdit and renders originalNode ?? children
    • the no-op fix
    • the Tab fix

Not in this PR

  • Crowded Playlist header. With three buttons the header squeezes at the example page's width, and the track-count title wraps.

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Bundle size impact

json-edit-react

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 CarlosNZ left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 onKeyDown to its own textarea and also mounts originalNode has Tab inserted into the JSON textarea, at its old caret position, instead of crashing. It belongs in the same follow-up.
  • originalNodeKey. The passOriginalNode comment in types.ts#L602-L606 mentions originalNode/originalNodeKey, but collection components never receive originalNodeKey. It's worth saying so.
  • Rendering originalNode once. The README could say to render it at most once. The element carries a fixed textarea id/name from pathString, the node's textAreaRef and the editor-wide editConfirmRef, so a second copy would have duplicate IDs and a contested ref.
  • Demo ternary. isEditing && jsonMode ? null : isEditing ? … : … (Example.tsx#L148) reads more easily as isEditing ? (jsonMode ? null : toolbar) : viewButtons.

🤖 Generated with Claude Code

CarlosNZ and others added 3 commits September 29, 2026 23:15
`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>
@CarlosNZ

Copy link
Copy Markdown
Owner Author

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 (stringifiedValue === null), handleEdit commits data itself, and only a typed buffer is parsed, inside the try/catch. The engine's existing no-op path then closes the session and still fires commit* and onCommit, with no onUpdate or apply. This covers ✓ as well as displace, for plain collections and for a showOnEdit component's own session.

Three regression tests, each seen failing first:

  • displacing an untouched raw-JSON edit
  • confirming an untouched raw-JSON edit with ✓
  • displacing a component's own untouched session

Each asserts that neither onUpdate nor setData fires. It matches the contract the commit-on-displace test block already states ("an unchanged one closes via commit* (no setData)"). One limit: text typed and then changed back to the original still counts as touched, because catching that would mean serialising on every commit. There's a CHANGELOG line for the fix.

2. Playlist demo never resets jsonMode. Fixed in a9a0f91.

  • An effect resets the mode to the toolbar whenever isEditing goes false, so sessions opened via ✎ or editorRef.startEdit always start in the default.
  • Reorder… goes back to a plain setIsEditing(true).
  • I checked in Chrome: after Edit as JSON and then ✓, the ✎ on tracks: opens the toolbar.
  • The README bullet and the example's header comment now say to reset the mode when isEditing goes false, not only to choose it before setIsEditing.

3. Behaviour change in a patch release. Added to the #411 CHANGELOG bullet in a9a0f91: a collection definition that already sets passOriginalNode with showOnEdit and renders originalNode ?? children now shows the JSON editor while editing. It also tells readers to render children explicitly to keep the rows.

Minor: Tab in a component's own textarea. Folded into this PR, in 81b8930, rather than a follow-up. The Tab insertion in onKeyDownEdit now runs only when e.target === textAreaRef.current; anywhere else the key falls through to handleKeyboard, which has no Tab binding there, so the browser default applies. That one condition covers both forms: the crash when the ref is unattached, and the tab landing in the JSON textarea when originalNode is mounted. Two regression tests, one with originalNode mounted and one without, are both seen failing first. There's a CHANGELOG line for the fix.

Minor: originalNodeKey. The passOriginalNode comment in types.ts and the README bullet now say that collections get no originalNodeKey, because the key renders in the collection's own header.

Minor: render originalNode once. The README bullet now says to render it at most once, since it carries the node's textarea and confirm-button refs.

Minor: demo ternary. Now isEditing ? (jsonMode ? null : toolbar) : viewButtons.

Full suite green (34 suites), lint and tsc clean. The PR description is updated to match.

🤖 Generated with Claude Code

@CarlosNZ

Copy link
Copy Markdown
Owner Author

Rechecked at a9a0f91: all seven fixes hold up, the suite is green and tsc is clean. One more problem turned up, and originalNode is what exposes it.

A sync onUpdate reject leaves the rejected JSON in the buffer

What happens. A component offers its own mode and "edit as JSON", like the Playlist.

  1. The user types JSON into originalNode and clicks ✓. onUpdate rejects it synchronously (return { error: '…' }).
  2. The session closes, but the rejected text stays in stringifiedValue.
  3. The user opens the component's own mode (e.g. Reorder…) and changes nothing.
  4. The user double-clicks another row. Commit-on-displace finds a non-null buffer, so it parses and re-submits the rejected JSON. onUpdate rejects it again, and the clicked row doesn't open. The error belongs to a node whose visible editor is its own toolbar, with no JSON in sight.

Checked with a jest test and real data/setData state: onUpdate receives the rejected value twice, setData never fires, and the clicked row doesn't open.

Why. On a reject before applying, the engine closes the session and clears cancelOp without calling it (EditingProvider.tsx#L526-L537). onCommit never runs, so clearEditBuffer is skipped on both routes. The untouched-buffer check added in b32837f then sees a touched buffer.

Scope.

Suggested fix. Clear the buffer when the commit is declined or rejected before being applied (CollectionNode.tsx#L303-L305):

}).then((outcome) => {
  if (outcome?.status === 'error' || outcome?.status === 'cancel') clearEditBuffer()
  if (outcome?.status === 'error') onError(outcome.error, value as CollectionData)
})

A sync reject then leaves the node as an async reject does, and a plain collection reopens on its current data rather than the rejected text. If you'd rather a plain collection kept showing the rejected text on reopen, clear the buffer when the component's setIsEditing opens a session instead.

Regression test. Sync reject from originalNode, open the component's own session, displace. Assert that onUpdate was called only once and that the clicked row opens.

🤖 Generated with Claude Code

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>
@CarlosNZ
CarlosNZ merged commit 32a875d into main Sep 29, 2026
2 checks passed
CarlosNZ added a commit that referenced this pull request Sep 29, 2026
- 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>
@CarlosNZ
CarlosNZ deleted the 411-pass-through-JSON-edditor branch September 29, 2026 10:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom collection nodes: expose the raw-JSON editor for composition (the collection counterpart of StringEdit)

1 participant