diff --git a/.changeset/fix-circular-custom-revivers.md b/.changeset/fix-circular-custom-revivers.md new file mode 100644 index 0000000..273f1cb --- /dev/null +++ b/.changeset/fix-circular-custom-revivers.md @@ -0,0 +1,5 @@ +--- +'devalue': patch +--- + +fix: resolve circular references through custom revivers when payload is already hydrated diff --git a/src/parse.js b/src/parse.js index e847571..de0d628 100644 --- a/src/parse.js +++ b/src/parse.js @@ -78,6 +78,10 @@ export function unflatten(parsed, revivers) { i = values.push(value[1]) - 1; } + if (i in hydrated) { + return (hydrated[index] = reviver(hydrated[i])); + } + hydrating ??= new Set(); if (hydrating.has(i)) { diff --git a/test/index.test.js b/test/index.test.js index a80125a..4f7f66d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1682,3 +1682,81 @@ asyncErrorTests('throws for promise resolving to function', async () => { }); asyncErrorTests.run(); + +const circularCustomTypes = uvu.suite('circular references through custom types'); + +circularCustomTypes('resolves circular reference through two custom types', () => { + const foo = new Foo({ name: 'outer' }); + const bar = new Bar({ name: 'inner', ref: foo }); + foo.value.ref = bar; + + const reducers = { + Foo: (x) => x instanceof Foo && x.value, + Bar: (x) => x instanceof Bar && x.value + }; + const fooCache = new WeakMap(); + const barCache = new WeakMap(); + const revivers = { + Foo: (x) => { + let inst = fooCache.get(x); + if (!inst) { + inst = Object.create(Foo.prototype); + fooCache.set(x, inst); + } + inst.value = x; + return inst; + }, + Bar: (x) => { + let inst = barCache.get(x); + if (!inst) { + inst = Object.create(Bar.prototype); + barCache.set(x, inst); + } + inst.value = x; + return inst; + } + }; + + const json = stringify(foo, reducers); + const result = parse(json, revivers); + + assert.ok(result instanceof Foo); + assert.ok(result.value.ref instanceof Bar); + assert.is(result.value.ref.value.ref, result); +}); + +circularCustomTypes('resolves self-referencing custom type', () => { + const foo = new Foo({ name: 'self' }); + foo.value.ref = foo; + + const reducers = { + Foo: (x) => x instanceof Foo && x.value + }; + const fooCache = new WeakMap(); + const revivers = { + Foo: (x) => { + let inst = fooCache.get(x); + if (!inst) { + inst = Object.create(Foo.prototype); + fooCache.set(x, inst); + } + inst.value = x; + return inst; + } + }; + + const json = stringify(foo, reducers); + const result = parse(json, revivers); + + assert.ok(result instanceof Foo); + assert.is(result.value.ref, result); +}); + +circularCustomTypes('still rejects genuinely invalid circular reference', () => { + assert.throws( + () => parse('[["Custom", 0]]', { Custom: (v) => v }), + (error) => error.message === 'Invalid circular reference' + ); +}); + +circularCustomTypes.run();