From d411e2d497c0e18b2929f80884da446d029b7a2c Mon Sep 17 00:00:00 2001 From: Brian Willows Date: Thu, 10 Sep 2026 08:17:08 +0100 Subject: [PATCH] fix: don't re-walk an already-dereferenced $ref target When a $ref is replaced, this.update(newValue) lets traverse descend into the substituted value. The same resolved object is substituted at every site that points at that ref, so its subtree is walked again on each substitution - and when refs nest, every level is re-walked once per path that reaches it. A schema where each definition references the one below it twice is a DAG of n definitions but is traversed as 2^n nodes. deref() is synchronous, so this blocks the event loop: definitions input before after 10 1.1KB 13.5ms 4.1ms 18 1.9KB 579.6ms 0.8ms 22 2.3KB 9,556ms 1.5ms 24 2.6KB 38,250ms 2.8ms Track which resolved values have already been walked and skip descending into them again. Resolved refs are already shared (the same object is substituted everywhere), so this changes nothing observable: verified byte-for-byte identical output across 400 generated schemas covering nested, shared, repeated and missing refs, and the existing 41 tests pass. Co-Authored-By: Claude Opus 4.8 --- lib/index.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/index.js b/lib/index.js index 7447a2f..d1f5704 100644 --- a/lib/index.js +++ b/lib/index.js @@ -251,7 +251,17 @@ function derefSchema (schema, options, state) { delete node.$ref newValue = _.merge({}, newValue, node) } - this.update(newValue) + // Only walk into a resolved value the first time it is substituted. + // The same object is reused at every site pointing at that ref, so + // re-walking it repeats work already done - and when refs nest, each + // level is re-walked once per path that reaches it, which is + // exponential in the number of definitions. + const alreadyResolved = + newValue !== null && typeof newValue === 'object' && state.resolved.has(newValue) + if (newValue !== null && typeof newValue === 'object') { + state.resolved.add(newValue) + } + this.update(newValue, alreadyResolved) if (state.missing.indexOf(refVal) !== -1) { state.missing.splice(state.missing.indexOf(refVal), 1) } @@ -293,7 +303,11 @@ function deref (schema, options) { circularRefs: [], cwd: cwd, missing: [], - history: [] + history: [], + // Values already dereferenced during this call. A resolved ref is shared - + // the same object is substituted at every site that points at it - so once + // one has been walked there is nothing left to do on later substitutions. + resolved: new WeakSet() } try {