Skip to content

fix: reject Map keys that are not strings and symbol keys in string patch paths in development builds - #189

Merged
unadlib merged 7 commits into
mainfrom
fix/string-path-keys
Oct 6, 2026
Merged

unadlib merged 7 commits into
mainfrom
fix/string-path-keys

Conversation

@unadlib

@unadlib unadlib commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Part of #168.

Summary

With enablePatches: { pathAsArray: false }, patch paths are JSON Pointer strings, and escapePath() turns every key into a string. An external review that led to #184 noted under its finding B2 that a string path cannot hold a Map key with object identity; #184 fixed the array-path case only. The string-path case is broader, on main and on npm 1.3.0 alike:

Recipe, with pathAsArray: false Patch apply(base, patches)
draft.set(1, 'b') on Map { 1 => 'a' } /1 Map { 1 => 'a', '1' => 'b' }
draft.set(true, 'b') /true adds the key 'true'
draft.set(key, 'b') with key = ['k'] /k adds the key 'k'
draft.set(key, 'b') with key = { id: 1 } /[object Object] adds the key '[object Object]'
draft.get(1).v = 2 /1/v throws Cannot apply patch at '1/v'
a change at a symbol key of a Map or an object — create() throws TypeError: Cannot convert a Symbol value to a string

The first four write another key without any error. A JSON Pointer holds only strings, so these keys cannot be encoded. With this PR, development builds throw a clear error for them, and the production artifacts stay byte-identical to main.

Why development builds only

The first version of this PR threw in production builds too, with a new error code 21. Reviewing it for correctness and necessity found:

  • The setup is contradictory: string paths exist for JSON interoperability, and Maps are not JSON. Nothing has been reported, and only the cases that write another key are silent; the others already throw.
  • The production check cost 142 B raw and 30 B Brotli in the production CJS artifact.
  • Development builds, which test runners load as well, catch the misuse with the same message. The development-only check that rejects a mark() copy function together with patches or auto-freeze treats a similar misuse the same way.
  • getPath() checked each key while walking up, before it knew whether the path resolves. When a changed Map left its key afterwards (moved, deleted, replaced, or reordered by reverse()), the first version threw although main and 1.3.0 generate correct patches there: the stale path is dropped, and the parent's patches carry the Map's value.

Production builds keep the v1 behavior, which the docs describe.

Changes, one commit per item

  1. fix: reject Map keys that are not strings and symbol keys in string patch paths: checkPathKey() runs where keys enter a path, in getPath() for the parents of a changed draft (Sets keep their positions) and in generatePatchesFromAssigned() for assigned keys. The message names the key's type rather than its value, because String(key) prints ['k'] as k and throws for objects without a prototype. Adds test/string-path-keys.test.ts, error code 21 and a migration guide bullet; item 5 removes the code and moves the bullet.
  2. docs: note that string patch paths support only string Map keys and no symbol keys.
  3. build: refresh the size baseline for the string patch path key check.
  4. fix: check string patch path keys only once the path resolves: getPath() checks a key after the recursive call returns a path, so a path that a level above drops is never checked. Adds a test of five such recipes, which fails on item 1.
  5. fix: check string patch path keys only in development builds: both call sites run under __DEV__ and the message is inline, as for the other development-only checks, so error code 21, its row on the errors page and its production test are gone. The type comes from original, because Terser kept an unused destructured type (7 B). The migration guide bullet moves from "Patches" to "Development builds", and a production test pins that production builds skip the check.
  6. docs: say that development builds reject keys a string patch path cannot name: the option docs in the README, create.md and the patches guide. The previous wording also did not fit symbol keys, which never produced patches.
  7. build: refresh the size baseline for the development check of string patch path keys.

String Map keys, including keys with / and ~, default array paths and producers without patches are unchanged.

Behavior

Recipe, with pathAsArray: false main and 1.3.0 Development builds Production builds
A change at a Map key that is not a string replay writes another key throws as main
A change below such a key replay throws, or changes another entry when the Map also holds the string key throws as main
A change at a symbol key TypeError throws TypeError, as main
A change below such a key, after its Map moved, was deleted or replaced, or was reordered by reverse() correct patches correct patches correct patches

Known limitations

Development builds can throw for a recipe whose patches hold no such key:

  • A change below such a key that the recipe restores, such as push() then pop() on an array, setting an entry of a Map back, or reversing an array twice. The path is built before the patches show that nothing changed; avoiding this needs a check at patch emission, which is not worth the code for a recipe that changes nothing.
  • A change below such a key followed by returning an unchanged child draft, such as draft.map.get(1).v = 2; return draft.other;. The returned value replaces the patches with one replace at the root, but the patches of the changed drafts are still generated, and their paths are checked. Skipping patch generation when a recipe returns a value would remove this case for 26 B raw in the production CJS artifact.

With the two-step API, const [draft, finalize] = create(base, { enablePatches: { pathAsArray: false } }), a finalize() that throws this error leaves the drafts alive, and calling finalize() again returns a state that holds a revoked draft and patches without the failed part. Recipe producers revoke their drafts on any error since #184; the two-step API does not, and this check adds an error that can be thrown while it finalizes. Revoking there would cost 46 B raw in the production CJS artifact.

These cases need recipes that are rare even among those that use string paths with such keys, and they only affect development builds, so none of them is worth production bytes. A differential fuzz of 50,000 random recipes against the production build found no patch path holding such a key that development builds let through; every development error without one came from the first two cases.

Verification

  • The full Node CI sequence passes locally: lint, format, build, the benchmark checks, test:benchmarks, size, test:package, test:build-watch, type-check and test (4,668 tests). Coverage of src is 100% of lines, branches and functions.
  • 4 of the 8 tests in test/string-path-keys.test.ts fail on main: the three reject tests and the revocation test. The test of containers that left their key fails on item 1.
  • The development and production builds were run against the recipes above, the moved and restored cases, and npm 1.3.0: development builds throw for each case of the first table and generate correct patches for the moved cases; production builds behave as main.

Size

The production artifacts (CJS, UMD and ESM) are byte-identical to main, so production runs the same code as before.

Measurement main This PR Δ
Production CJS / UMD / ESM artifacts, Brotli 8,114 / 8,171 / 8,158 same bytes 0
Development CJS / UMD artifacts, raw 70,367 / 72,588 70,872 / 73,099 +505 / +511
Development ESM artifacts (esm.js / esm.mjs), raw 71,135 / 71,136 71,724 / 71,725 +589 / +589

Consumer bundles built with NODE_ENV=production keep their raw sizes; their Brotli sizes move by −4 to +11 B. size-limit measures 8.3, 7.27 and 8.16 kB, as on main, against the caps of 8.4, 7.4 and 8.3 kB.

@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

Coverage after merging fix/string-path-keys 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.ts100%100%100%100%
   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 Oct 6, 2026

Copy link
Copy Markdown

Coverage after merging fix/string-path-keys 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.ts100%100%100%100%
   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: reject Map keys that are not strings and symbol keys in string patch paths fix: reject Map keys that are not strings and symbol keys in string patch paths in development builds Oct 6, 2026
@unadlib
unadlib merged commit 2ab58fc into main Oct 6, 2026
4 checks passed
@unadlib
unadlib deleted the fix/string-path-keys branch October 6, 2026 20:30
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.

1 participant