Resolve synchronous onUpdate verdicts in place (no setData write on sync reject)#391
Merged
Merged
Conversation
Bundle size impact
|
| Format | Base raw | PR raw | Δ raw | Base gzip | PR gzip | Δ gzip |
|---|---|---|---|---|---|---|
| esm | 58.04 KB | 58.31 KB | 🔺 +285 B (+0.48%) | 20.71 KB | 20.79 KB | 🔺 +82 B (+0.39%) |
| cjs | 59.54 KB | 59.82 KB | 🔺 +285 B (+0.47%) | 20.75 KB | 20.83 KB | 🔺 +87 B (+0.41%) |
Measured from build/index.{cjs,esm}.js. Gzip at level 9.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR refines the v2 optimistic editing pipeline so that synchronous onUpdate verdicts are resolved immediately without performing an optimistic setData write (and therefore without a revert write on sync reject). This prevents downstream consumers of setData (e.g. undo/history, autosave, dirty flags) from observing transient, never-accepted snapshots.
Changes:
- Added a sync/async split to the update pipeline:
runUpdatenow returns synchronously whenonUpdateis synchronous, and returns a Promise only whenonUpdateis genuinely async. - Implemented an editor-op “sync fast-path” in the commit engine to skip optimistic apply for synchronous verdicts, plus a reconcile tweak to close non-held editing sessions on pre-apply reject/cancel.
- Expanded tests (including an integration test using
useUndo) and updated README wording to describe the new sync vs async behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| test/useUndo.test.tsx | Adds an end-to-end integration test ensuring sync-rejected edits don’t pollute the undo stack, plus a sanity test for a valid edit producing one undo step. |
| test/JsonEditor.test.tsx | Updates lifecycle expectations and adds coverage distinguishing sync reject (no write; cancel*) vs async reject (optimistic write then revert) and validates sync success still commits once. |
| test/imperativeHandle.test.tsx | Updates imperative confirm() sync-reject expectations to ensure there is no transient setData write. |
| src/utils/misc.ts | Introduces isThenable helper to distinguish synchronous returns from async thenables. |
| src/JsonEditor.tsx | Reworks runUpdate to avoid forcing synchronous results into a microtask and to normalize outcomes synchronously when possible. |
| src/contexts/EditingProvider.tsx | Adds the synchronous editor-op fast-path and adjusts reconcile’s pre-apply close behavior for sync rejects/cancels. |
| README_V2.md | Updates documentation to clearly distinguish setData (local UI state) from onUpdate (external effects) and explains sync vs async optimistic behavior and lifecycle event implications. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
CarlosNZ
commented
Jun 29, 2026
CarlosNZ
left a comment
Owner
Author
There was a problem hiding this comment.
Just missing a CHANGELOG entry (targeting beta8)
In the optimistic editing model an editor-op commit (edit / rename / object-add) applied synchronously before the onUpdate verdict was known, then reverted if rejected — so a rejected edit wrote to setData twice (apply + revert) for a value that was never accepted. This polluted anything downstream of setData, notably useUndo's history, which recorded a snapshot of the rejected value. When onUpdate returns synchronously its verdict is known in the same tick, so there's no need to apply optimistically. runUpdate now returns the outcome synchronously (no async wrapper) and submit() skips the optimistic apply for editor ops, handing the verdict straight to reconcile. A sync reject writes nothing to setData (no value-flash, clean undo history); a valid edit still commits once. Async onUpdate is unchanged — it still applies optimistically to hide latency. This is the symmetric completion of the instant-op behaviour (delete / move / array-add already pre-empt a sync reject via their settle delay). Events for a sync reject become submit* -> cancel* -> updateError (no commit*). Updated the affected tests, added regression coverage including an end-to-end useUndo test, and documented the sync/async split plus the data/setData-vs-onUpdate distinction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ed0fb35 to
990d9cb
Compare
CarlosNZ
added a commit
that referenced
this pull request
Jun 29, 2026
Sync after PR #391 (sync-reject engine fix + beta.8 CHANGELOG) was squash-merged to main. Only conflict was test/useUndo.test.tsx, resolved to this branch's superset (the engine-PR sync tests plus the useUndo async-reject corrector tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
In the v2 optimistic editing model, an editor-op commit (value edit / key rename / add-to-object) called
apply()synchronously — before the consumer'sonUpdateverdict was known — thenrevert()d ifonUpdaterejected. Both writes go throughsetData, so a rejected edit produced twosetDatacalls (apply + revert) for a value that was never accepted.This pollutes anything downstream of
setData. It surfaced via@json-edit-react/utils'useUndo: a synchronously rejected edit wrote a bogus snapshot into the undo stack, so "Undo" stepped back to the illegal value.The engine already avoided this for instant ops (delete / move / array-add) via their ~100ms settle delay — but editor ops opted out because a delay on the editor close would be perceptible. Sync-ness, however, is directly detectable: when
onUpdatereturns a non-thenable, the verdict is known in the same tick, so there's no need to apply optimistically at all.Change
runUpdateis now sync-or-async (src/JsonEditor.tsx): it callsonUpdatedirectly (extractednormalise/toErrorhelpers) and returns the outcome synchronously unlessonUpdateis genuinely async. Return type widened toUpdateOutcome | Promise<UpdateOutcome>.submit()sync fast-path (src/contexts/EditingProvider.tsx): on a synchronous editor-op verdict, skip the optimisticapply()and hand the outcome straight toreconcile— whose existing!applied()branch already applies for commit/override and stays put for error/cancel.reconcile()widened its pre-apply close condition to also close a non-held'editing'session, so a sync reject closes + reverts exactly as before.isThenablehelper in src/utils/misc.ts.Instant, held, and async paths are unchanged.
Behaviour
For synchronous
onUpdaterejects of editor ops:setDatais not called (was: apply + revert) → cleanuseUndohistory, no autosave/dirty-flag flicker, no value-flash.submit* → cancel* → updateError(wascommit* → updateError), matching how instant ops already report sync rejects.Unchanged: valid commits (one
setData+updateSuccess); all async rejects (still optimistic apply-then-revert); instant ops;hold()/ confirm flows.Tests & docs
JsonEditor.test.tsx,imperativeHandle.test.tsx).cancel*), a valid sync commit, an async-add optimistic-then-revert guard, and an end-to-enduseUndotest (sync-rejected edit leaves the undo stack clean).pnpm compileandpnpm lintclean.data/setData(local state) vsonUpdate(external writes), updated the lifecycle note.Follow-up (not in this PR)
Async-validation clean history via an
onEditEvent-gateduseUndois the remaining piece — async rejects still write apply-then-revert; consumers wanting clean history under async validation usehold()today. Deliberately scoped out;useUndois untouched here.🤖 Generated with Claude Code