From 733c160d4f2548469e2dca01be09bf48b5ce7965 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:41:49 +0000 Subject: [PATCH] fix(javascript): escape repeated prefixes in front of copied-slot selections Member-mutation normalization keeps any path carrying a copied-slot selection exact and never prunes it, but the repeated-prefix rule only escaped a repeating prefix when its projected origins proved it unprunable. A self alias in front of a spread selection, such as `const t = { ...e.opts }; e = e.value; consume(t)` with a parameter `e`, therefore grew `[value, value, ..., opts, ]` without bound until the worker heap was exhausted. The owner of both rules now reports a selection-carrying path as never pruned, so the prefix escapes once as designed. Obsidian's bundled pdf.js viewer (Fluent's FluentDateTime constructor) exhausted a 6 GB heap in 36 s and now completes its semantic phase in 0.9 s. Semantic IR is byte-identical on the 151 comparable real files. Co-Authored-By: Claude Opus 5.5 --- ...manticAnalysis.reference_lifetimes.test.ts | 21 +++++++++++++++++++ .../javascriptSemanticMemberMutations.ts | 19 +++++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts index ed98d2626f..910a026471 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts @@ -665,3 +665,24 @@ describe("references captured before an intermediate rebind", () => { }, ); }); + +describe("self aliases in front of copied-slot selections", () => { + // Normalization keeps copied-slot selections exact, so a self alias in front + // of one must escape its repeated prefix instead of growing the path. + it.each([ + ["seed.opts.inner.token", "unknown"], + ["seed.value.opts.inner.token", "unknown"], + ["seed.keep", "literal"], + ])( + "escapes the repeated prefix: %s", + (read, status) => { + const start = performance.now(); + const value = resultValue( + `const seed = { keep: 3, opts: { inner: { token: 1 } }, value: { opts: { inner: { token: 2 } } } }; let e = flag ? seed : seed.value; const t = { ...e.opts }; e = e.value; consume(t); return ${read};`, + ); + expect(performance.now() - start).toBeLessThan(2000); + expect(value?.status).toBe(status); + }, + 30000, + ); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 1400d25258..9a895e950c 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -385,7 +385,11 @@ export function* collectSemanticMemberMutationsSteps( } if ( repeatedPrefix !== undefined && - observablePaths.repeatsUnpruned(binding.bindingId, repeatedPrefix) + observablePaths.repeatsUnpruned( + binding.bindingId, + repeatedPrefix, + current.path, + ) ) { markValue( current.node, @@ -1066,6 +1070,10 @@ interface MutationObserverNode { }; } +/** Copied-slot selections (from spreads and rests) stay exact path keys. */ +const carriesSelection = (path: PropertyPath): boolean => + path.some((key) => key !== null && typeof key === "object"); + const observableMutationPaths = ( state: JavaScriptSemanticAnalysisState, ): { @@ -1077,6 +1085,7 @@ const observableMutationPaths = ( readonly repeatsUnpruned: ( bindingId: string, prefix: PropertyPath, + path: PropertyPath, ) => boolean; } => { const bindingNodes = new Map(); @@ -1299,9 +1308,7 @@ const observableMutationPaths = ( effect: ValueEffect, ): PropertyPath | null => { let observer = bindingNodes.get(bindingId); - if (observer === undefined) return path; - if (path.some((key) => key !== null && typeof key === "object")) - return path; + if (observer === undefined || carriesSelection(path)) return path; if (effect === "escape") { observer = project(observer, path); settle(); @@ -1323,11 +1330,15 @@ const observableMutationPaths = ( // repeating a prefix revisits an origin set, every longer repetition stays // observable and normalization can never prune it. The answer depends only // on the binding and prefix, so every revisit of a long chain shares it. + // Normalization keeps copied-slot selections exact, so it never prunes a + // path carrying one, however often a prefix repeats in front of it. const repeated = new Map(); const repeatsUnpruned = ( bindingId: string, prefix: PropertyPath, + path: PropertyPath, ): boolean => { + if (carriesSelection(path)) return true; const key = JSON.stringify([bindingId, prefix]); const cached = repeated.get(key); if (cached !== undefined) return cached;