fix(javascript): bound self-alias mutation paths that never prune

Detect unprunable repeated self-alias prefixes at the JavaScript mutation owner, record conservative escape effects, and reuse settled repeated-prefix checks. Preserve finite-path precision and deeper-write uncertainty.

Refs #1665.

Co-authored-by: Martin Klein <36695017+flitzrrr@users.noreply.github.com>
This commit is contained in:
Martin Klein authored and GitHub committed 2026-10-11 17:54:25 +08:00
1 parent 817aef4068
commit e365f3715f
2 files changed
+113 -11

No files matched your search

@@ -46,7 +46,7 @@ export function* collectSemanticMemberMutationsSteps(
state: JavaScriptSemanticAnalysisState,
): Generator<void, void> {
const parents = state.parentsByNode;
const normalizePath = observableMutationPaths(state);
const observablePaths = observableMutationPaths(state);
const returnedReferences = createSemanticReturnedReferences(state);
const recordedEffects = new Set<string>();
const selections = new Map<string, readonly string[]>();
@@ -128,18 +128,48 @@ export function* collectSemanticMemberMutationsSteps(
// An alias restored from a saved reference can revisit the same binding
// at an earlier capture or a shorter getter-receiver path. Only a
// repeated capture and path closes a cycle.
const lifetime = JSON.stringify([
const capture = [
binding.bindingId,
current.originAt?.start,
current.originAt?.end,
current.path,
]);
];
const lifetime = JSON.stringify([...capture, current.path]);
if (current.bindings.has(lifetime)) continue;
// A self alias such as `node = node[key]` revisits its capture with the
// same prefix added on every cycle, so exact paths never repeat. When
// that prefix can never be pruned, escaping it once covers every longer
// effect, including writes below shared children.
let repeatedPrefix: PropertyPath | undefined;
for (let start = 1; start <= current.path.length; start++) {
const suffix = current.path.slice(start);
if (!current.bindings.has(JSON.stringify([...capture, suffix])))
continue;
repeatedPrefix = current.path.slice(0, start);
break;
}
if (
repeatedPrefix !== undefined &&
observablePaths.repeatsUnpruned(binding.bindingId, repeatedPrefix)
) {
markValue(
current.node,
repeatedPrefix,
new Set(),
"escape",
mutation,
current.originAt,
);
continue;
}
// Union compatible copied-slot selections at each binding. Carrying every
// combination of exclusions through branch diamonds is exponential.
// Preserve paths that can change observed slots or Array prototype
// effects; canonicalize suffixes that cannot distinguish lattice facts.
let path = normalizePath(binding.bindingId, current.path, effect);
let path = observablePaths.normalize(
binding.bindingId,
current.path,
effect,
);
if (path === null) continue;
const selectorIndex = path.findIndex(
(key) => key !== null && typeof key === "object",
@@ -698,11 +728,17 @@ interface MutationObserverNode {
const observableMutationPaths = (
state: JavaScriptSemanticAnalysisState,
): ((
bindingId: string,
path: PropertyPath,
effect: ValueEffect,
) => PropertyPath | null) => {
): {
readonly normalize: (
bindingId: string,
path: PropertyPath,
effect: ValueEffect,
) => PropertyPath | null;
readonly repeatsUnpruned: (
bindingId: string,
prefix: PropertyPath,
) => boolean;
} => {
const bindingNodes = new Map<string, MutationObserverNode>();
const expressions = new WeakMap<t.Node, MutationObserverNode>();
const pendingExpressions: {
@@ -897,7 +933,11 @@ const observableMutationPaths = (
}
};
settle();
return (bindingId, path, effect) => {
const normalize = (
bindingId: string,
path: PropertyPath,
effect: ValueEffect,
): PropertyPath | null => {
let observer = bindingNodes.get(bindingId);
if (observer === undefined) return path;
if (path.some((key) => key !== null && typeof key === "object"))
@@ -919,6 +959,44 @@ const observableMutationPaths = (
}
return path;
};
// A projection's origins depend only on its source origins and key. Once
// 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.
const repeated = new Map<string, boolean>();
const repeatsUnpruned = (
bindingId: string,
prefix: PropertyPath,
): boolean => {
const key = JSON.stringify([bindingId, prefix]);
const cached = repeated.get(key);
if (cached !== undefined) return cached;
let observer = bindingNodes.get(bindingId);
const signatures = new Set<string>();
let result = false;
while (observer !== undefined && prefix.length > 0) {
observer = project(observer, prefix);
settle();
if ((observer.origins?.size ?? 0) === 0) break;
const signature = JSON.stringify(
[...(observer.origins ?? [])]
.map((origin) =>
typeof origin === "string"
? origin
: `${String(origin.start)}:${String(origin.end)}`,
)
.sort(compareUnicodeCodePoints),
);
if (signatures.has(signature)) {
result = true;
break;
}
signatures.add(signature);
}
repeated.set(key, result);
return result;
};
return { normalize, repeatsUnpruned };
};
const arrayReferencedValues = (