Skip to content

chore: split uneval into an internal analyze/render emitter - #223

Open
elliott-with-the-longest-name-on-github wants to merge 3 commits into
mainfrom
elliott/uneval-emit
Open

elliott-with-the-longest-name-on-github wants to merge 3 commits into
mainfrom
elliott/uneval-emit

Conversation

@elliott-with-the-longest-name-on-github

@elliott-with-the-longest-name-on-github elliott-with-the-longest-name-on-github commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Splits uneval's internals into two steps, so unevalStream (#218) can reuse the emitter without growing uneval a pile of special cases. The split also gives the stream a clean point between walk and render: once the walk has run, it knows whether the value contained any promises, and a head without them is just analysis.render(), identical to uneval. Nothing public changes and the output is byte-for-byte the same.

const analysis = analyze(value, { replace, known, enter, reserved }); // walk + name allocation
analysis.render({ reference, retain, primitive }); // build the string, at most once

uneval(value, replacer) is now just analyze(value, { replace }).render(). Basically, the walk stays exactly where it was, and everything after walk(value) moves into render. Render reuses the walk's state (seen, names), so calling it twice throws instead of quietly producing garbage. known returns a token (the stream uses its own Location) and render passes that same token to reference, so there's no second lookup. The rule for which primitives are worth a slot (strings ≥128 chars, bigints with ≥128-char literals) now lives in the emitter, so primitive only gets called for those.

Hooks are only checked where they were before. A plain uneval call costs one extra object (the Analysis), and the no-hook path reuses a shared frozen {} instead of allocating hook objects. Typical cases in pnpm bench:compare fall inside run-to-run noise. On the micro end (small object, 100k calls of a 3-key object) it's ~3% slower, about the same as the hooks version this replaces. That's the per-call closure setup, not per-node work.

There's also a separate commit fixing the benchmark imports, which have pointed at ../../index.js since #209 moved it to src/. Without it, bench:compare doesn't run on main at all.

test/uneval-hooks.test.js covers each hook. It also checks that analyze(v).render() matches uneval(v) across a spread of fixtures, that a second render throws, that tokens make the round trip, and that short primitives never reach primitive.

@changeset-bot

changeset-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: d38e7b8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@elliott-with-the-longest-name-on-github
elliott-with-the-longest-name-on-github added this pull request to stack #224 October 8, 2026 21:46
@elliott-with-the-longest-name-on-github elliott-with-the-longest-name-on-github changed the title elliott/uneval emit chore: split uneval into an internal analyze/render emitter Oct 8, 2026
@elliott-with-the-longest-name-on-github
elliott-with-the-longest-name-on-github marked this pull request as ready for review October 9, 2026 16:38
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