From 9c5d5997c2577a43d5dbf0e63d17cb4ed20916f9 Mon Sep 17 00:00:00 2001 From: Sverre Johansen Date: Wed, 2 Sep 2026 12:16:53 +0200 Subject: [PATCH] fix(router-utils): clone cached nodes to release Babel's traversal cache `extractModuleInfoFromAst` stored each `var` binding's initializer as the `t.Expression` node from the parsed file. `StartCompiler.moduleCache` holds that module info for as long as the module is known, so in a dev server the node stays reachable for the life of the process. `@babel/traverse` keys its `NodePath` and `Scope` caches on node identity (`WeakMap`), which is what lets a file's traversal state be collected once the AST is dropped. Holding one node from a file defeats that for the whole file: the surviving cache entry holds `NodePath`s whose `parentPath` chain reaches the Program path, and whose `scope` reaches every binding in the file. In a heap snapshot of our dev server these retained `NodePath` graphs survived a full GC. Store a location-free deep clone instead. The clone has a fresh identity, so it is not a key in either WeakMap and pins nothing, and dropping `loc` also drops the `Position`/`SourceLocation` objects. The readers (`resolveBindingKind`, `resolveExprKind`) only look at node types and `callee`/`object`/`property`/`name`, so a clone is equivalent for them. Measured on our app: 11.6% less peak dev-server memory over four paired cold runs, with byte-identical build output. --- .changeset/start-compiler-module-info-ast-retention.md | 5 +++++ packages/router-utils/src/compiler-helpers.ts | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 .changeset/start-compiler-module-info-ast-retention.md diff --git a/.changeset/start-compiler-module-info-ast-retention.md b/.changeset/start-compiler-module-info-ast-retention.md new file mode 100644 index 00000000000..871e90b7f3f --- /dev/null +++ b/.changeset/start-compiler-module-info-ast-retention.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-utils': patch +--- + +Stop the Start compiler's module cache from retaining parsed ASTs. `extractModuleInfoFromAst` now stores a detached clone of each binding's initializer, so a cached module no longer keeps the `@babel/traverse` `NodePath` and `Scope` graph of the file it came from reachable. diff --git a/packages/router-utils/src/compiler-helpers.ts b/packages/router-utils/src/compiler-helpers.ts index 5460c349141..4b9bdd2641a 100644 --- a/packages/router-utils/src/compiler-helpers.ts +++ b/packages/router-utils/src/compiler-helpers.ts @@ -95,7 +95,9 @@ function addVariableDeclarationModuleInfo( for (const name of collectIdentifiersFromPattern(declarator.id)) { bindings.set(name, { type: 'var', - init: declarator.init ?? null, + // Detached: `@babel/traverse` keys its caches on node identity, so + // storing the original would pin this file's whole traversal graph. + init: declarator.init ? t.cloneNode(declarator.init, true, true) : null, }) exportMap?.set(name, name) } @@ -552,7 +554,9 @@ export function extractModuleInfoFromAst(ast: t.File): ExtractedModuleInfo { const synth = '__default_export__' bindings.set(synth, { type: 'var', - init: t.isExpression(declaration) ? declaration : null, + init: t.isExpression(declaration) + ? t.cloneNode(declaration, true, true) + : null, }) exportMap.set('default', synth) }