Skip to content

fix: preserve draft finalization state and producer ownership - #218

Merged
unadlib merged 8 commits into
mainfrom
codex/fix-shared-draft-patches
Nov 1, 2026
Merged

unadlib merged 8 commits into
mainfrom
codex/fix-shared-draft-patches

Conversation

@unadlib

@unadlib unadlib commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

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] 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:

Version #217 duplicate insertion Parent-before-child replay Premature outer-draft finalization
v1.3.0 Passes Passes Reproduces
v2.0.0 Reproduces Reproduces Reproduces
v2.0.1 Reproduces Reproduces Reproduces
Final PR head (d3a075eb) Fixed Fixed Fixed

Validation

The final diff adds 252 test cases:

  • 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.
  • Website build passed.

@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

Coverage after merging codex/fix-shared-draft-patches into main will be

100.00%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   apply.ts100%100%100%100%
   array.ts100%100%100%100%
   constant.ts100%100%100%100%
   create.ts100%100%100%100%
   current.ts100%100%100%100%
   draft.ts100%100%100%100%
   draftify.ts100%100%100%100%
   error.ts100%100%100%100%
   index.ts100%100%100%100%
   interface.ts100%100%100%100%
   internal.ts100%100%100%100%
   makeCreator.ts100%100%100%100%
   map.ts100%100%100%100%
   original.ts100%100%100%100%
   patch.ts99.61%99.10%100%100%111
   rawReturn.ts100%100%100%100%
   set.ts100%100%100%100%
   unsafe.ts100%100%100%100%
src/utils
   cast.ts100%100%100%100%
   copy.ts100%100%100%100%
   deepFreeze.ts100%100%100%100%
   draft.ts100%100%100%100%
   finalize.ts100%100%100%100%
   forEach.ts100%100%100%100%
   index.ts100%100%100%100%
   mark.ts100%100%100%100%
   marker.ts100%100%100%100%
   proto.ts100%100%100%100%

@unadlib unadlib changed the title fix: prevent duplicate shared-draft patches introduced in v2 fix: preserve draft finalization state and producer ownership Nov 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

Coverage after merging codex/fix-shared-draft-patches into main will be

100.00%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   apply.ts100%100%100%100%
   array.ts100%100%100%100%
   constant.ts100%100%100%100%
   create.ts100%100%100%100%
   current.ts100%100%100%100%
   draft.ts100%100%100%100%
   draftify.ts100%100%100%100%
   error.ts100%100%100%100%
   index.ts100%100%100%100%
   interface.ts100%100%100%100%
   internal.ts100%100%100%100%
   makeCreator.ts100%100%100%100%
   map.ts100%100%100%100%
   original.ts100%100%100%100%
   patch.ts99.61%99.10%100%100%111
   rawReturn.ts100%100%100%100%
   set.ts100%100%100%100%
   unsafe.ts100%100%100%100%
src/utils
   cast.ts100%100%100%100%
   copy.ts100%100%100%100%
   deepFreeze.ts100%100%100%100%
   draft.ts100%100%100%100%
   finalize.ts100%100%100%100%
   forEach.ts100%100%100%100%
   index.ts100%100%100%100%
   mark.ts100%100%100%100%
   marker.ts100%100%100%100%
   proto.ts100%100%100%100%

@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown

Coverage after merging codex/fix-shared-draft-patches into main will be

100.00%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   apply.ts100%100%100%100%
   array.ts100%100%100%100%
   constant.ts100%100%100%100%
   create.ts100%100%100%100%
   current.ts100%100%100%100%
   draft.ts100%100%100%100%
   draftify.ts100%100%100%100%
   error.ts100%100%100%100%
   index.ts100%100%100%100%
   interface.ts100%100%100%100%
   internal.ts100%100%100%100%
   makeCreator.ts100%100%100%100%
   map.ts100%100%100%100%
   original.ts100%100%100%100%
   patch.ts99.61%99.10%100%100%111
   rawReturn.ts100%100%100%100%
   set.ts100%100%100%100%
   unsafe.ts100%100%100%100%
src/utils
   cast.ts100%100%100%100%
   copy.ts100%100%100%100%
   deepFreeze.ts100%100%100%100%
   draft.ts100%100%100%100%
   finalize.ts100%100%100%100%
   forEach.ts100%100%100%100%
   index.ts100%100%100%100%
   mark.ts100%100%100%100%
   marker.ts100%100%100%100%
   proto.ts100%100%100%100%

@unadlib
unadlib merged commit 3f6ebc0 into main Nov 1, 2026
10 checks passed
@unadlib
unadlib deleted the codex/fix-shared-draft-patches branch November 1, 2026 13:13
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.

Fix duplicate patches when drafts are shared

1 participant