From dc4eb2d7ebe73d059394cb28e060431a668ac9a6 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 8 Oct 2026 15:36:07 -0600 Subject: [PATCH 1/4] chore: fix benchmark imports --- benchmarking/benchmarks/typed-array.js | 2 +- benchmarking/benchmarks/uneval.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmarking/benchmarks/typed-array.js b/benchmarking/benchmarks/typed-array.js index 0241ab47..e890e74e 100644 --- a/benchmarking/benchmarks/typed-array.js +++ b/benchmarking/benchmarks/typed-array.js @@ -1,6 +1,6 @@ import { fastest_test } from '../utils.js'; -import { parse, stringify } from '../../index.js'; +import { parse, stringify } from '../../src/index.js'; const value_small = new Uint8Array(Array.from({ length: 100 }, (_, i) => i)); const value_medium = new Uint8Array(Array.from({ length: 10 * 1024 }, (_, i) => i % 256)); diff --git a/benchmarking/benchmarks/uneval.js b/benchmarking/benchmarks/uneval.js index a0009f6f..dafb5a5c 100644 --- a/benchmarking/benchmarks/uneval.js +++ b/benchmarking/benchmarks/uneval.js @@ -1,4 +1,4 @@ -import { uneval } from '../../index.js'; +import { uneval } from '../../src/index.js'; import { records } from '../records.js'; import { fastest_test } from '../utils.js'; From 6a264fc46e73f6d0d0afcb652ff8b753bdc271bf Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 8 Oct 2026 15:35:16 -0600 Subject: [PATCH 2/4] refactor: split uneval into an internal analyze/render emitter --- src/uneval.js | 227 +++++++++++++++++--- src/utils.js | 22 ++ test/uneval-hooks.test.js | 428 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 644 insertions(+), 33 deletions(-) create mode 100644 test/uneval-hooks.test.js diff --git a/src/uneval.js b/src/uneval.js index 912f5d54..d08b42a4 100644 --- a/src/uneval.js +++ b/src/uneval.js @@ -1,10 +1,11 @@ /** + * @import { JavaScriptSource } from './javascript-source.js'; * @import { UnevalReplacer } from './types.js'; */ -import { js, JavaScriptSource } from './javascript-source.js'; import { DevalueError, MAP_KEY, + call_replacer, enumerable_symbols, escaped, format_path, @@ -28,9 +29,87 @@ const MIN_STRING_LENGTH = 128; * @param {UnevalReplacer} [replacer] */ export function uneval(value, replacer) { + return analyze( + value, + replacer ? { replace: (thing) => call_replacer(thing, replacer) } : NO_HOOKS + ).render(NO_HOOKS); +} + +/** + * Walk hooks for the internal emitter behind `uneval`, used by streaming sessions. + * Not part of the public API. + * @template T + * @typedef {object} AnalyzeHooks + * @property {(thing: object) => JavaScriptSource | null} [replace] + * Returns the cached replacement for an object, or `null` for none. When present, + * it replaces the direct replacer call; the caller guarantees it invokes the user + * replacer at most once per identity. + * @property {(thing: object) => T | undefined} [known] + * Returns a token for an object that already exists on the client, or `undefined`. + * Such an object is not walked, the replacer is not consulted, and every use emits + * the expression `reference` returns for the token. + * @property {(thing: object, parent?: object, key?: string | number) => void} [enter] + * Called when the walk enters an object this emission creates: once per object, + * before its contents are walked. When `parent` is given, the client object is reachable as + * `parent[key]` once the emitted code has run. Otherwise (the root, Map and + * Set entries, replacer interpolations) there is no such path. Not called for + * objects `known` accepts. + * @property {string[]} [reserved] + * Identifiers generated names must never use. + */ + +/** + * Render hooks for the internal emitter behind `uneval`. Not part of the public API. + * @template T + * @typedef {object} RenderHooks + * @property {(token: T) => string} [reference] + * Client expression for an object `known` accepted, given the token `known` returned. + * @property {(thing: object) => string | undefined} [retain] + * Client slot expression (e.g. `s.o[3]`) that is assigned exactly once, when the + * object is first created in this emission. + * @property {(thing: string | bigint, literal: string) => string | undefined} [primitive] + * Optional expression for a rendered primitive and its escaped literal. Only called + * for strings of at least `MIN_STRING_LENGTH` characters and bigints whose literal is + * at least that long, including for interpolations and local declarations, never for + * hoisting cost estimates. Return `undefined` to keep the literal. + */ + +/** + * The result of walking a value, ready to be rendered once. + * @template T + * @typedef {object} Analysis + * @property {Map} references + * Every object `known` accepted, with its token. Must not be mutated. + * @property {(hooks?: RenderHooks) => string} render + * Renders the value. Can only be called once. + */ + +/** @type {Map} */ +const NO_REFERENCES = new Map(); + +const NO_HOOKS = Object.freeze({}); + +/** + * Internal emitter behind `uneval`: walks the value and allocates names. With no + * hooks, `analyze(value).render()` is identical to `uneval(value)`. + * @template T + * @param {any} value + * @param {AnalyzeHooks} [hooks] + * @returns {Analysis} + */ +export function analyze(value, hooks = NO_HOOKS) { + const { replace, known, enter } = hooks; + + /** @type {RenderHooks['reference']} */ + let reference; + /** @type {RenderHooks['retain']} */ + let retain; + /** @type {RenderHooks['primitive']} */ + let primitive; + /** @type {Map} */ const names = new Map(); - const reserved = new Set(); + const reserved = new Set(hooks.reserved); const templates = new Set(); const seen = new Set(); @@ -42,6 +121,12 @@ export function uneval(value, replacer) { /** @type {Map} */ const custom = new Map(); + /** + * Objects the `known` hook says already exist on the client, with their tokens + * @type {Map | null} + */ + let references = null; + /** * @param {string} message * @param {any} thing @@ -75,7 +160,25 @@ export function uneval(value, replacer) { } /** @param {any} thing */ - function walk(thing) { + function render_primitive(thing) { + const literal = stringify_cached_primitive(thing); + if ( + primitive !== undefined && + (typeof thing === 'string' + ? thing.length >= MIN_STRING_LENGTH + : typeof thing === 'bigint' && literal.length >= MIN_STRING_LENGTH) + ) { + return primitive(thing, literal) ?? literal; + } + return literal; + } + + /** + * @param {any} thing + * @param {any} [parent] + * @param {string | number} [key] + */ + function walk(thing, parent, key) { if (!is_primitive(thing)) { if (seen.has(thing)) { if (!names.has(thing)) names.set(thing, ''); @@ -84,19 +187,21 @@ export function uneval(value, replacer) { seen.add(thing); - if (replacer) { - const fragment = replacer(thing, js); + const token = known?.(thing); + if (token !== undefined) { + (references ??= new Map()).set(thing, token); + return; + } - if (fragment) { - const source = JavaScriptSource.from(fragment); + if (enter) enter(thing, parent, key); + + if (replace) { + const source = replace(thing); + if (source) { custom.set(thing, source); source.visit(walk, reserved, templates); return; } - - if (fragment !== undefined && fragment !== null && fragment !== false) { - throw new TypeError('Invalid uneval replacer result'); - } } if (typeof thing === 'function') { @@ -123,7 +228,7 @@ export function uneval(value, replacer) { // Never scan the logical length of a dictionary-backed sparse array. for (const i of valid_array_indices(thing)) { keys.push(+i); - walk(thing[i]); + walk(thing[i], thing, +i); keys.pop(); } break; @@ -156,7 +261,7 @@ export function uneval(value, replacer) { case 'BigUint64Array': case 'DataView': // Buffer pools can contain unrelated, sensitive bytes. - if (!is_buffer(thing)) walk(thing.buffer); + if (!is_buffer(thing)) walk(thing.buffer, thing, 'buffer'); return; case 'ArrayBuffer': @@ -187,7 +292,7 @@ export function uneval(value, replacer) { } keys.push(key); - walk(thing[key]); + walk(thing[key], thing, key); keys.pop(); } } @@ -225,9 +330,8 @@ export function uneval(value, replacer) { } } - // Reuse the traversal set to track declarations during serialization. - seen.clear(); - const inlining = custom.size > 0 ? new Set() : null; + /** @type {Set | null} */ + let inlining = null; /** @type {Map void>} */ const initializers = new Map(); @@ -235,6 +339,40 @@ export function uneval(value, replacer) { /** @type {string[]} */ const statements = []; + /** + * Assign `expression` to the object's client slot, if it has one. Only called + * where the object is created: in its single `let` declaration when named, or + * at its only position when inlined. Discarded renderings may also call it, so + * tracking "already assigned" here would be wrong. + * @param {any} thing + * @param {string} expression + */ + function retained(thing, expression) { + if (retain === undefined) return expression; + const slot = retain(thing); + return slot === undefined ? expression : `(${slot}=${expression})`; + } + + /** + * Whether a view's buffer must be emitted by `stringify` rather than copied + * inline, because the client already has it or it needs a slot. + * @param {any} buffer + */ + function is_shared(buffer) { + return ( + (references !== null && references.has(buffer)) || + (retain !== undefined && retain(buffer) !== undefined) + ); + } + + /** + * @param {any} thing + * @returns {boolean} + */ + function is_opaque(thing) { + return custom.has(thing) || (references !== null && references.has(thing)); + } + /** * @param {any} thing * @returns {string} @@ -246,16 +384,16 @@ export function uneval(value, replacer) { if (!seen.has(thing)) { if (is_primitive(thing)) { seen.add(thing); - statements.push(`let ${name}=${stringify_cached_primitive(thing)}`); + statements.push(`let ${name}=${render_primitive(thing)}`); return name; } - const type = custom.has(thing) ? null : get_type(thing); + const type = is_opaque(thing) ? null : get_type(thing); switch (type) { case 'Object': seen.add(thing); statements.push( - `let ${name}=${Object.getPrototypeOf(thing) === null ? 'Object.create(null)' : '{}'}` + `let ${name}=${retained(thing, Object.getPrototypeOf(thing) === null ? 'Object.create(null)' : '{}')}` ); Object.keys(thing).forEach((key) => { statements.push(`${name}${safe_prop(key)}=${stringify(thing[key])}`); @@ -269,7 +407,7 @@ export function uneval(value, replacer) { thing.length > 32 + 2 * indices.length ? stringify_sparse_array(thing.length) : `Array(${thing.length})`; - statements.push(`let ${name}=${array}`); + statements.push(`let ${name}=${retained(thing, array)}`); for (const i of indices) { statements.push(`${name}[${i}]=${stringify(thing[i])}`); } @@ -291,7 +429,7 @@ export function uneval(value, replacer) { initialized = true; initializers.delete(thing); statements.push( - `let ${name}=new ${type}${entries.length ? `([${entries.join(',')}])` : ''}` + `let ${name}=${retained(thing, `new ${type}${entries.length ? `([${entries.join(',')}])` : ''}`)}` ); }; @@ -319,7 +457,7 @@ export function uneval(value, replacer) { default: const str = actually_stringify(thing); - if (!seen.has(thing)) statements.push(`let ${name}=${str}`); + if (!seen.has(thing)) statements.push(`let ${name}=${retained(thing, str)}`); seen.add(thing); } } else { @@ -330,10 +468,12 @@ export function uneval(value, replacer) { } if (is_primitive(thing)) { - return stringify_cached_primitive(thing); + return render_primitive(thing); } - if (inlining === null) return actually_stringify(thing); + // An unnamed object occurs once in the output, so every rendering of it is + // its first use. Renderings discarded below never carry the only assignment. + if (inlining === null) return retained(thing, actually_stringify(thing)); // A singly referenced value can still be part of a custom constructor's // cycle. If inlining it re-enters itself, hoist it to break the cycle. @@ -345,7 +485,7 @@ export function uneval(value, replacer) { inlining.add(thing); const str = actually_stringify(thing); inlining.delete(thing); - return names.get(thing) || str; + return names.get(thing) || retained(thing, str); } /** @@ -353,6 +493,12 @@ export function uneval(value, replacer) { * @returns {string} */ function actually_stringify(thing) { + if (references !== null && references.has(thing)) { + return /** @type {NonNullable['reference']>} */ (reference)( + /** @type {T} */ (references.get(thing)) + ); + } + const source = custom.get(thing); if (source) { @@ -488,7 +634,7 @@ export function uneval(value, replacer) { let str = `new ${type}`; - if (!names.has(thing.buffer)) { + if (!names.has(thing.buffer) && !is_shared(thing.buffer)) { str += `([${stringify_typed_array_elements(type, thing.buffer)}])`; } else { str += `(${stringify(thing.buffer)})`; @@ -507,7 +653,7 @@ export function uneval(value, replacer) { case 'DataView': { let str = `new DataView`; - if (!names.has(thing.buffer)) { + if (!names.has(thing.buffer) && !is_shared(thing.buffer)) { str += `(new Uint8Array([${new Uint8Array(thing.buffer)}]).buffer`; } else { str += `(${stringify(thing.buffer)}`; @@ -548,13 +694,28 @@ export function uneval(value, replacer) { } } - const str = stringify(value); + let rendered = false; - if (statements.length === 0) { - return str; - } + return { + references: references ?? NO_REFERENCES, + render(hooks = NO_HOOKS) { + if (rendered) throw new Error('render() can only be called once'); + rendered = true; + ({ reference, retain, primitive } = hooks); + + // Reuse the traversal set to track declarations during serialization. + seen.clear(); + if (custom.size > 0) inlining = new Set(); - return `(function(){${statements.join(';')};return ${str}}())`; + const str = stringify(value); + + if (statements.length === 0) { + return str; + } + + return `(function(){${statements.join(';')};return ${str}}())`; + } + }; } /** diff --git a/src/utils.js b/src/utils.js index ce3bd7bd..93ca7819 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,8 @@ +/** + * @import { UnevalReplacer } from './types'; + */ import { MAX_ARRAY_INDEX, MAX_ARRAY_LEN } from './constants.js'; +import { js, JavaScriptSource } from './javascript-source.js'; const name_chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_$'; const reserved_names = @@ -87,6 +91,24 @@ export function is_plain_object(thing) { ); } +/** + * Calls an `uneval` replacer and validates its result + * @param {any} thing + * @param {UnevalReplacer} replacer + * @returns {JavaScriptSource | null} + */ +export function call_replacer(thing, replacer) { + const fragment = replacer(thing, js); + + if (fragment) return JavaScriptSource.from(fragment); + + if (fragment !== undefined && fragment !== null && fragment !== false) { + throw new TypeError('Invalid uneval replacer result'); + } + + return null; +} + /** @param {any} thing */ export function get_type(thing) { return Object.prototype.toString.call(thing).slice(8, -1); diff --git a/test/uneval-hooks.test.js b/test/uneval-hooks.test.js new file mode 100644 index 00000000..1bc122e3 --- /dev/null +++ b/test/uneval-hooks.test.js @@ -0,0 +1,428 @@ +import { describe, expect, test } from 'vitest'; +import { js, JavaScriptSource } from '../src/javascript-source.js'; +import { analyze, uneval } from '../src/uneval.js'; + +/** + * Evaluates emitted code the way a client would, with `s` bound to its session table. + * @param {string} source + * @param {any} session + */ +function evaluate_on_client(source, session) { + return new Function('s', 'Wrapper', `return (${source})`)(session, Wrapper); +} + +/** + * Builds a hook that returns the expression mapped to an object, or `undefined`. + * @param {Array<[object, string]>} entries + */ +function expressions_for(entries) { + const map = new Map(entries); + return (/** @type {object} */ thing) => map.get(thing); +} + +/** + * Walks and renders a value in one go. + * @template T + * @param {any} value + * @param {import('../src/uneval.js').AnalyzeHooks & import('../src/uneval.js').RenderHooks} [hooks] + */ +function emit(value, hooks = {}) { + const { reference, retain, primitive, ...walk_hooks } = hooks; + return analyze(value, walk_hooks).render({ reference, retain, primitive }); +} + +/** + * Builds `known`/`reference` hooks for objects that already exist on the client. + * The token is the client expression itself. + * @param {Array<[object, string]>} entries + */ +function existing_hooks(entries) { + const map = new Map(entries); + return { + known: (/** @type {object} */ thing) => map.get(thing), + reference: (/** @type {string} */ expression) => expression + }; +} + +/** + * @param {string} source + * @param {string} target + */ +function count_assignments(source, target) { + return source.split(`${target}=`).length - 1; +} + +/** + * @param {string} source + * @param {string} name + */ +function declares(source, name) { + return source.includes(`let ${name}=`); +} + +/** An object that throws if `emit` tries to serialize its contents. */ +function must_not_be_walked() { + return { + get contents() { + throw new Error('emit walked into an object the client already has'); + } + }; +} + +class Wrapper { + /** @param {any} wrapped */ + constructor(wrapped) { + this.wrapped = wrapped; + } +} + +/** @param {any} thing */ +const replace_wrappers = (thing) => + thing instanceof Wrapper ? JavaScriptSource.from(js`new Wrapper(${thing.wrapped})`) : null; + +describe('reference', () => { + test('emits the client expression instead of serializing the object', () => { + const server_user = must_not_be_walked(); + const client = { user: { name: 'already on the client' } }; + + const source = emit( + { author: server_user, editor: server_user }, + existing_hooks([[server_user, 's.user']]) + ); + const result = evaluate_on_client(source, client); + + expect(source).toContain('s.user'); + expect(result.author).toBe(client.user); + expect(result.editor).toBe(client.user); + }); + + test('takes precedence over replace', () => { + const server_user = new Wrapper('server copy'); + /** @type {unknown[]} */ + const replaced = []; + + emit( + { user: server_user }, + { + ...existing_hooks([[server_user, 's.user']]), + replace: (thing) => { + replaced.push(thing); + return replace_wrappers(thing); + } + } + ); + + expect(replaced).not.toContain(server_user); + }); +}); + +describe('replace', () => { + test('is called once per object, however many times the object is reached', () => { + const shared = { shared: true }; + const wrapper = new Wrapper(shared); + /** @type {Map} */ + const calls = new Map(); + + emit( + { shared, also_shared: shared, in_array: [shared], wrapper, also_wrapper: wrapper }, + { + replace: (thing) => { + calls.set(thing, (calls.get(thing) ?? 0) + 1); + return replace_wrappers(thing); + } + } + ); + + expect(calls.get(shared)).toBe(1); + expect(calls.get(wrapper)).toBe(1); + }); +}); + +describe('retain', () => { + test('stores a Set member in its slot', () => { + const member = { x: 1 }; + const client = { o: [] }; + + const source = emit(new Set([member]), { retain: expressions_for([[member, 's.o[0]']]) }); + const set = evaluate_on_client(source, client); + + expect(client.o[0]).toBe([...set][0]); + }); + + test('stores a cyclic object in its slot exactly once', () => { + /** @type {any} */ + const cyclic = { x: 1 }; + cyclic.self = cyclic; + const client = { o: [] }; + + const source = emit({ cyclic }, { retain: expressions_for([[cyclic, 's.o[0]']]) }); + const result = evaluate_on_client(source, client); + + expect(client.o[0]).toBe(result.cyclic); + expect(result.cyclic.self).toBe(result.cyclic); + expect(count_assignments(source, 's.o[0]')).toBe(1); + }); + + test('stores hoisted objects in their slots exactly once', () => { + const shared = { x: 1 }; + const map = new Map([[shared, shared]]); + const client = { o: [] }; + + const source = emit( + { shared, in_array: [shared], map }, + { + retain: expressions_for([ + [shared, 's.o[0]'], + [map, 's.o[1]'] + ]) + } + ); + const result = evaluate_on_client(source, client); + + expect(client.o[0]).toBe(result.shared); + expect(client.o[1]).toBe(result.map); + expect(result.in_array[0]).toBe(result.shared); + expect(result.map.get(result.shared)).toBe(result.shared); + expect(count_assignments(source, 's.o[0]')).toBe(1); + expect(count_assignments(source, 's.o[1]')).toBe(1); + }); + + test('stores a replaced object in its slot', () => { + const wrapper = new Wrapper('contents'); + const client = { o: [] }; + + const source = emit( + { wrapper }, + { replace: replace_wrappers, retain: expressions_for([[wrapper, 's.o[0]']]) } + ); + const result = evaluate_on_client(source, client); + + expect(client.o[0]).toBe(result.wrapper); + expect(result.wrapper).toBeInstanceOf(Wrapper); + expect(result.wrapper.wrapped).toBe('contents'); + }); + + test("stores a typed array's buffer in its slot", () => { + const array = new Uint8Array([1, 2, 3]); + const client = { o: [] }; + + const source = emit({ array }, { retain: expressions_for([[array.buffer, 's.o[0]']]) }); + const result = evaluate_on_client(source, client); + + expect(client.o[0]).toBe(result.array.buffer); + expect([...result.array]).toEqual([1, 2, 3]); + }); +}); + +describe('enter', () => { + test('reports each created object once, with the parent and key that reach it', () => { + const shared = { x: 1 }; + const key = { k: 1 }; + const member = { m: 1 }; + const array = new Uint8Array([1, 2]); + const wrapper = new Wrapper({ inner: true }); + const value = { shared, again: shared, list: [shared, member], map: new Map([[key, 1]]) }; + Object.assign(value, { set: new Set([member]), array, wrapper, 'odd key': {} }); + + /** @type {Array<[unknown, unknown, unknown]>} */ + const calls = []; + emit(value, { + replace: replace_wrappers, + enter: (thing, parent, key) => calls.push([thing, parent, key]) + }); + + expect(calls).toEqual([ + [value, undefined, undefined], + [shared, value, 'shared'], + [value.list, value, 'list'], + [member, value.list, 1], + [value.map, value, 'map'], + [key, undefined, undefined], + [value.set, value, 'set'], + [array, value, 'array'], + [array.buffer, array, 'buffer'], + [wrapper, value, 'wrapper'], + [wrapper.wrapped, undefined, undefined], + [value['odd key'], value, 'odd key'] + ]); + }); + + test('is not called for referenced objects or their contents', () => { + const existing = { child: {} }; + /** @type {unknown[]} */ + const things = []; + + emit( + { existing }, + { + ...existing_hooks([[existing, 's.e']]), + enter: (thing) => things.push(thing) + } + ); + + expect(things).not.toContain(existing); + expect(things).not.toContain(existing.child); + }); + + test('is not called for the pooled buffer of a Node Buffer', () => { + const buffer = Buffer.from('hi'); + /** @type {unknown[]} */ + const things = []; + + emit({ buffer }, { enter: (thing) => things.push(thing) }); + + expect(things).not.toContain(buffer.buffer); + }); +}); + +describe('reserved', () => { + test('generated names never shadow identifiers used by hook expressions', () => { + const server_user = must_not_be_walked(); + const enough_repeats_to_generate_s = Array.from({ length: 100 }, (_, i) => ({ i })); + const value = { + user: server_user, + first: enough_repeats_to_generate_s, + second: [...enough_repeats_to_generate_s] + }; + const hooks = existing_hooks([[server_user, 's.user']]); + const client = { user: { name: 'already on the client' } }; + + expect(declares(emit(value, hooks), 's')).toBe(true); + + const source = emit(value, { ...hooks, reserved: ['s'] }); + const result = evaluate_on_client(source, client); + + expect(declares(source, 's')).toBe(false); + expect(result.user).toBe(client.user); + expect(result.second[42]).toBe(result.first[42]); + }); +}); + +describe('analyze', () => { + /** @type {any} */ + const cyclic = { name: 'cyclic' }; + cyclic.self = cyclic; + const shared = { shared: true }; + const long = 'x'.repeat(200); + const sparse = []; + sparse[100] = 1; + + test.each([ + ['number', 42], + ['negative zero', -0], + ['undefined', undefined], + ['string', 'hello '], + ['bigint', 10n ** 140n], + ['object', { a: 1, 'odd key': [1, 2, 3] }], + ['null-prototype object', Object.assign(Object.create(null), { x: 1 })], + ['repeated reference', { a: shared, b: shared, c: [shared] }], + ['cycle', cyclic], + ['repeated long string', [long, long, long]], + ['repeated bigint', [10n ** 140n, 10n ** 140n]], + ['sparse array', sparse], + ['map and set', new Map([[shared, new Set([shared, 1])]])], + ['typed arrays', { a: new Uint8Array([1, 2]), b: new Float64Array([-0, 1.5]) }], + [ + 'shared buffer', + (() => { + const buffer = new ArrayBuffer(4); + return [new Uint8Array(buffer), new DataView(buffer)]; + })() + ], + ['dates and regexps', [new Date(0), /a.b/gi, new URL('https://example.com')]], + ['boxed primitives', [Object(1), Object('s'), Object(true), Object(1n)]] + ])('render() without hooks matches uneval: %s', (_name, value) => { + expect(analyze(value).render()).toBe(uneval(value)); + }); + + test('render() without hooks matches uneval with a replacer', () => { + const wrapper = new Wrapper(shared); + const value = { wrapper, again: wrapper, shared }; + /** @param {any} thing @param {any} js */ + const replacer = (thing, js) => + thing instanceof Wrapper ? js`new Wrapper(${thing.wrapped})` : null; + + expect(emit(value, { replace: replace_wrappers })).toBe(uneval(value, replacer)); + }); + + test('render can only be called once', () => { + const analysis = analyze({ a: 1 }); + analysis.render(); + + expect(() => analysis.render()).toThrow('render() can only be called once'); + }); + + test('reference receives the token known returned', () => { + const first = must_not_be_walked(); + const second = must_not_be_walked(); + const first_token = { slot: 0 }; + const second_token = { slot: 1 }; + const tokens = new Map([ + [first, first_token], + [second, second_token] + ]); + /** @type {unknown[]} */ + const received = []; + + const analysis = analyze( + { first, second, again: first, other: {} }, + { + known: (thing) => tokens.get(thing) + } + ); + const source = analysis.render({ + reference: (token) => { + received.push(token); + return `s.o[${token.slot}]`; + } + }); + + expect(received).toEqual([first_token, second_token]); + expect(received[0]).toBe(first_token); + expect(received[1]).toBe(second_token); + expect(source).toContain('s.o[0]'); + expect(source).toContain('s.o[1]'); + }); + + test('references lists exactly the objects known accepted, with their tokens', () => { + const existing = { existing: true }; + const nested = { nested: true }; + const fresh = { fresh: true }; + + const analysis = analyze( + { existing, list: [nested, fresh], again: existing }, + { known: (thing) => (thing === existing ? 'a' : thing === nested ? 'b' : undefined) } + ); + + expect([...analysis.references]).toEqual([ + [existing, 'a'], + [nested, 'b'] + ]); + expect(analyze({ fresh }).references.size).toBe(0); + }); + + test('primitive is only called for long strings and bigints with long literals', () => { + const long_string = 'x'.repeat(128); + const long_bigint = 10n ** 130n; + const value = { + short: 'x'.repeat(127), + long: long_string, + small: 123n, + medium: 10n ** 125n, + big: long_bigint, + number: 1, + boxed: Object('y'.repeat(10)) + }; + /** @type {unknown[]} */ + const calls = []; + + emit(value, { + primitive: (thing) => { + calls.push(thing); + return undefined; + } + }); + + expect(calls).toEqual([long_string, long_bigint]); + }); +}); From e6d46da7bbf7ae96db21b5f7a316643df50b864e Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Thu, 8 Oct 2026 21:44:37 -0600 Subject: [PATCH 3/4] refactor: drop the unused references map from Analysis --- src/uneval.js | 6 ------ test/uneval-hooks.test.js | 17 ----------------- 2 files changed, 23 deletions(-) diff --git a/src/uneval.js b/src/uneval.js index d08b42a4..34c54ead 100644 --- a/src/uneval.js +++ b/src/uneval.js @@ -78,15 +78,10 @@ export function uneval(value, replacer) { * The result of walking a value, ready to be rendered once. * @template T * @typedef {object} Analysis - * @property {Map} references - * Every object `known` accepted, with its token. Must not be mutated. * @property {(hooks?: RenderHooks) => string} render * Renders the value. Can only be called once. */ -/** @type {Map} */ -const NO_REFERENCES = new Map(); - const NO_HOOKS = Object.freeze({}); /** @@ -697,7 +692,6 @@ export function analyze(value, hooks = NO_HOOKS) { let rendered = false; return { - references: references ?? NO_REFERENCES, render(hooks = NO_HOOKS) { if (rendered) throw new Error('render() can only be called once'); rendered = true; diff --git a/test/uneval-hooks.test.js b/test/uneval-hooks.test.js index 1bc122e3..22734a33 100644 --- a/test/uneval-hooks.test.js +++ b/test/uneval-hooks.test.js @@ -384,23 +384,6 @@ describe('analyze', () => { expect(source).toContain('s.o[1]'); }); - test('references lists exactly the objects known accepted, with their tokens', () => { - const existing = { existing: true }; - const nested = { nested: true }; - const fresh = { fresh: true }; - - const analysis = analyze( - { existing, list: [nested, fresh], again: existing }, - { known: (thing) => (thing === existing ? 'a' : thing === nested ? 'b' : undefined) } - ); - - expect([...analysis.references]).toEqual([ - [existing, 'a'], - [nested, 'b'] - ]); - expect(analyze({ fresh }).references.size).toBe(0); - }); - test('primitive is only called for long strings and bigints with long literals', () => { const long_string = 'x'.repeat(128); const long_bigint = 10n ** 130n; From 142d176e256b31d36bab90f973250d47b9ba0511 Mon Sep 17 00:00:00 2001 From: Elliott Johnson Date: Tue, 13 Oct 2026 16:17:44 -0600 Subject: [PATCH 4/4] chore: use explicit .js extension in type imports --- src/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.js b/src/utils.js index 93ca7819..9e2ff230 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,5 +1,5 @@ /** - * @import { UnevalReplacer } from './types'; + * @import { UnevalReplacer } from './types.js'; */ import { MAX_ARRAY_INDEX, MAX_ARRAY_LEN } from './constants.js'; import { js, JavaScriptSource } from './javascript-source.js';