From e465cac713a4d335a6f7ab1ac14f7224844d237f Mon Sep 17 00:00:00 2001 From: Abhishek Govindarasu Date: Wed, 3 Dec 2025 00:51:20 -0800 Subject: [PATCH 1/2] fix: add seen weakset during mergeDeep --- src/utils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 4a8ec09a..88ec6cbc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,3 @@ -import { isStringTextContainingNode } from 'typescript' import type { Sucrose } from './sucrose' import type { TraceHandler } from './trace' @@ -59,14 +58,19 @@ export const mergeDeep = < skipKeys?: string[] override?: boolean mergeArray?: boolean + seen?: WeakSet } ): A & B => { const skipKeys = options?.skipKeys const override = options?.override ?? true const mergeArray = options?.mergeArray ?? false + const seen = options?.seen ?? new WeakSet() if (!isObject(target) || !isObject(source)) return target as A & B + if (seen.has(source)) return target as A & B + seen.add(source) + for (const [key, value] of Object.entries(source)) { if ( skipKeys?.includes(key) || @@ -98,7 +102,7 @@ export const mergeDeep = < target[key as keyof typeof target] = mergeDeep( (target as any)[key] as any, value, - { skipKeys, override, mergeArray } + { skipKeys, override, mergeArray, seen } ) } catch {} } From 1819ba315afa5a08a89636cd6c33e8c58af8e0ae Mon Sep 17 00:00:00 2001 From: Abhishek Govindarasu Date: Wed, 3 Dec 2025 01:02:36 -0800 Subject: [PATCH 2/2] add tests --- src/utils.ts | 2 + test/units/merge-deep.test.ts | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/utils.ts b/src/utils.ts index 88ec6cbc..c564990e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -107,6 +107,8 @@ export const mergeDeep = < } catch {} } + seen.delete(source) + return target as A & B } export const mergeCookie = ( diff --git a/test/units/merge-deep.test.ts b/test/units/merge-deep.test.ts index f2767269..74e040a5 100644 --- a/test/units/merge-deep.test.ts +++ b/test/units/merge-deep.test.ts @@ -86,4 +86,78 @@ describe('mergeDeep', () => { .decorate('db', Object.freeze({ hello: 'world' })) .guard({}, (app) => app) }) + + it('handle circular references', () => { + const a: { + x: number + toB?: typeof b + } = { x: 1 } + const b: { + y: number + toA?: typeof a + } = { y: 2 } + + a.toB = b + b.toA = a + + const target = {} + const source = { prop: a } + + const result = mergeDeep(target, source) + + expect(result.prop.x).toBe(1) + expect(result.prop.toB?.y).toBe(2) + }) + + it('handle shared references in different branches', () => { + const shared = { value: 123 } + const target = { x: {}, y: {} } + const source = { x: shared, y: shared } + + const result = mergeDeep(target, source) + + expect(result.x.value).toBe(123) + expect(result.y.value).toBe(123) + }) + + it('deduplicate plugin with circular decorators', async () => { + const a: { + x: number + toB?: typeof b + } = { x: 1 } + const b: { + y: number + toA?: typeof a + } = { y: 2 } + a.toB = b + b.toA = a + + const complex = { a } + + const Plugin = new Elysia({ name: 'Plugin', seed: 'seed' }) + .decorate('dep', complex) + .as('scoped') + + const ModuleA = new Elysia({ name: 'ModuleA' }) + .use(Plugin) + .get('/moda/a', ({ dep }) => dep.a.x) + .get('/moda/b', ({ dep }) => dep.a.toB?.y) + + const ModuleB = new Elysia({ name: 'ModuleB' }) + .use(Plugin) + .get('/modb/a', ({ dep }) => dep.a.x) + .get('/modb/b', ({ dep }) => dep.a.toB?.y) + + const app = new Elysia().use(ModuleA).use(ModuleB) + + const resA = await app.handle(req('/moda/a')).then((x) => x.text()) + const resB = await app.handle(req('/modb/a')).then((x) => x.text()) + const resC = await app.handle(req('/moda/b')).then((x) => x.text()) + const resD = await app.handle(req('/modb/b')).then((x) => x.text()) + + expect(resA).toBe('1') + expect(resB).toBe('1') + expect(resC).toBe('2') + expect(resD).toBe('2') + }) })