Skip to content

fix: preserve shared-reference identity for Map keys in uneval#164

Open
chatman-media wants to merge 1 commit into
sveltejs:mainfrom
chatman-media:fix/uneval-map-key-dedupe
Open

fix: preserve shared-reference identity for Map keys in uneval#164
chatman-media wants to merge 1 commit into
sveltejs:mainfrom
chatman-media:fix/uneval-map-key-dedupe

Conversation

@chatman-media

Copy link
Copy Markdown

What

uneval does not deduplicate objects used as Map keys. When the same object is a key in more than one Map (or is a Map key and also appears elsewhere in the graph), uneval inlines a fresh copy at each occurrence, so the round-tripped value loses object identity:

import { uneval } from 'devalue';

const node1 = { id: 1 };
const node2 = { id: 2 };
const node3 = { id: 3 };

const map = new Map([
  [node1, new Map([[node2, 1], [node3, 1]])],
  [node2, new Map([[node1, 1], [node3, 1]])],
  [node3, new Map([[node1, 1], [node2, 1]])]
]);

uneval(map);
// before:
// new Map([[{id:1},new Map([[{id:2},1],[{id:3},1]])], ...])
//   → eval'd result has three *distinct* {id:1} objects,
//     so map's keys are no longer === the keys used inside the sub-maps

stringify / parse already handle this correctly — only uneval is affected. Closes #54.

Why

In the walk pass, the Map case only recursed into values, not keys:

case 'Map':
  for (const [key, value] of thing) {
    keys.push(...);
    walk(value);   // key was never walked
    keys.pop();
  }

Because keys never entered counts, a key that appears more than once was never recognized as a shared reference and never assigned a hoisted variable — so each occurrence was serialized inline as a fresh literal. (Set already walks its members, and stringify's flatten pass already walks both flatten(key) and flatten(value), which is why those paths were unaffected.)

Fix

Walk the key as well as the value. The existing stringify step already emits the hoisted name for any value that ended up in names, including keys reached through the Map entry arrays, so no other change is needed:

case 'Map':
  for (const [key, value] of thing) {
    keys.push(...);
    walk(key);
    walk(value);
    keys.pop();
  }

After the fix:

uneval(map);
// (function(a,b,c){a.id=1;b.id=2;c.id=3;return new Map([
//   [a,new Map([[b,1],[c,1]])],
//   [b,new Map([[a,1],[c,1]])],
//   [c,new Map([[a,1],[b,1]])]
// ])}({},{},{}))
//   → identity preserved across all sub-maps

Tests

Added two repetition fixtures (each exercised by the existing uneval / stringify / parse / unflatten / stringifyAsync round-trip suites, with validate callbacks asserting key identity):

Both new uneval fixtures fail on main and pass with this change; the stringify/parse variants pass either way (confirming the bug was uneval-only). Full suite: 692 passing.

@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4150460

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
devalue Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

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.

.uneval() does not handle nested maps properly

1 participant