You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix: preserve draft finalization state and producer ownership - #218
Fix patch replay for shared drafts by preserving their finalization state and generating each draft's patches at its own node, after its children. This also prevents an inner create() call from prematurely finalizing a draft owned by an outer call.
Problems fixed
Duplicate patches for shared drafts — v2 regression
The reproduction in #217 returns [0, 1] from create(), but replaying its patches produces [0, 1, 1].
The v2 patch-generation changes in 766cbb9 (#174) switched draft patch values from deepClone() to current(). A snapshot could clear a draft's existing finalized flag, allowing its patches to be generated again. current() now saves and restores that flag in finally.
v1.3.0 also clears the flag in current(), but uses deepClone() for patch values, so the #217 reproduction works correctly there.
Parent patches emitted before child patches — v2 regression
A draft placed at another location could generate its patches when finalization visited that location's old draft node. If this happened to a parent before its children were finalized, the parent's structural patches came first, even though the child patches still addressed the original layout.
For example, read an element of list: [[7, 4]], share the list into a later-read property, append 9 to the element, then unshift that element. The returned state contains [7, 4, 9] at list[0], but replay could produce [7, 4, 9, 9]: the list replacement already contained the edit, and the child's insertion was applied afterward.
Restoring the snapshot flag alone does not fix this ordering problem. A non-root draft now generates patches only when it is still at its own node's key. Its child nodes are processed first. If it has left that key, its final value is carried by the patches of the container holding it, rather than emitting child patches against its old path.
An inner producer finalizing an outer draft — also present in v1.3.0
If an inner recipe reads a slot and replaces it with an outer recipe's draft, visiting that slot must not generate patches for the outer draft or mark it finalized. Otherwise, the inner patches can contain outer-state paths, and later outer reads can skip drafting and mutate the base.
This also happens when either or both calls have patches disabled, because the finalization flag is set independently of patch output. Generating patches only at a draft's own node leaves that responsibility with its owning producer. The documented lifetime restrictions on sharing drafts between separate create() calls still apply.
Final implementation
current() restores the previous finalized flag on exit.
finalizeNode() finalizes the current node's Set values before writing its value or generating patches, including when the draft has moved.
Non-root patches are generated only when proxyDraft === node, preserving child-before-parent order. Root patches are generated at the root node.
Finding another draft at a node's key writes that draft's value into the parent. A replacement Set from the same producer can have its values finalized there; another producer remains responsible for its own draft. Neither replacement generates patches at the displaced node.
Version checks
These concrete reproductions were compared in both development and production builds:
210 shared-draft cases covering read order, nesting, auto-freeze, array/string paths, array-length assignment, removals and undo, strict mode, and Object/Map/Set drafts.
29 additional ordering cases covering arrays, object properties, Map values, reassignment back to the original key, explicit child-before-parent ordering, and a deterministic 1,000-recipe forward/inverse replay check.
12 outer/inner producer cases covering three placements and all four combinations of patch generation being enabled or disabled.
One direct check that current() preserves both the false and true finalization states.
At the final PR head, all 10 CI checks passed:
Full suite: 72 test files passed; 5,582 tests passed, 234 expected failures, and eight skipped. No unexpected failures. Line and function coverage are both 100%.
Node 22/24 builds, minimum-Node compatibility, declarations, source/test type checks, lint, formatting, API-doc consistency, package/consumer checks, and build-watch checks passed.
Absolute size limits and artifact/consumer size budgets passed with the existing baseline and budgets.
All five paired performance budget groups passed: core, maps, sets, objects, and apply/return/search.
unadlib
changed the title
fix: prevent duplicate shared-draft patches introduced in v2
fix: preserve draft finalization state and producer ownership
Nov 1, 2026
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
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.
Fixes #217.
Fix patch replay for shared drafts by preserving their finalization state and generating each draft's patches at its own node, after its children. This also prevents an inner
create()call from prematurely finalizing a draft owned by an outer call.Problems fixed
Duplicate patches for shared drafts — v2 regression
The reproduction in #217 returns
[0, 1]fromcreate(), but replaying its patches produces[0, 1, 1].The v2 patch-generation changes in 766cbb9 (#174) switched draft patch values from
deepClone()tocurrent(). A snapshot could clear a draft's existingfinalizedflag, allowing its patches to be generated again.current()now saves and restores that flag infinally.v1.3.0 also clears the flag in
current(), but usesdeepClone()for patch values, so the #217 reproduction works correctly there.Parent patches emitted before child patches — v2 regression
A draft placed at another location could generate its patches when finalization visited that location's old draft node. If this happened to a parent before its children were finalized, the parent's structural patches came first, even though the child patches still addressed the original layout.
For example, read an element of
list: [[7, 4]], share the list into a later-read property, append9to the element, then unshift that element. The returned state contains[7, 4, 9]atlist[0], but replay could produce[7, 4, 9, 9]: the list replacement already contained the edit, and the child's insertion was applied afterward.Restoring the snapshot flag alone does not fix this ordering problem. A non-root draft now generates patches only when it is still at its own node's key. Its child nodes are processed first. If it has left that key, its final value is carried by the patches of the container holding it, rather than emitting child patches against its old path.
An inner producer finalizing an outer draft — also present in v1.3.0
If an inner recipe reads a slot and replaces it with an outer recipe's draft, visiting that slot must not generate patches for the outer draft or mark it finalized. Otherwise, the inner patches can contain outer-state paths, and later outer reads can skip drafting and mutate the base.
This also happens when either or both calls have patches disabled, because the finalization flag is set independently of patch output. Generating patches only at a draft's own node leaves that responsibility with its owning producer. The documented lifetime restrictions on sharing drafts between separate
create()calls still apply.Final implementation
current()restores the previousfinalizedflag on exit.finalizeNode()finalizes the current node's Set values before writing its value or generating patches, including when the draft has moved.proxyDraft === node, preserving child-before-parent order. Root patches are generated at the root node.Version checks
These concrete reproductions were compared in both development and production builds:
d3a075eb)Validation
The final diff adds 252 test cases:
current()preserves both thefalseandtruefinalization states.At the final PR head, all 10 CI checks passed: