Describe the bug
With unpatched Solid 2.0.0-rc.13 and Router 2.0.0-next.33, GRAPH_GROWTH can
report retained route work even though every exit releases all of the previous
route's computations and returns to exactly the same live graph.
The initial navigation is a route declaration, emitted around Router context
construction before route content mounts. The graph-growth detector includes
that incomplete census in its history alongside completed navigation visits.
Small, bounded variation in subsequent page content then satisfies the growth
ratio because the first sample lacks the page altogether.
This is about the validity of the first comparison sample, not a request to
raise thresholds or disable leak detection. The reproduction includes a separate
positive control that deliberately retains roots and must still be detected.
Steps to reproduce
Use Node 24.21.0 and pnpm 12.8.1. In an empty directory, save this
package.json and the repro.ts below, then run:
pnpm install
node --conditions=browser --conditions=development repro.ts
{
"private": true,
"type": "module",
"packageManager": "pnpm@12.8.1",
"dependencies": {
"@solidjs/diagnostics": "2.0.0-rc.13",
"@solidjs/router": "2.0.0-next.33",
"@solidjs/web": "2.0.0-rc.13",
"jsdom": "30.1.1",
"solid-js": "2.0.0-rc.13"
}
}
The fixture mounts /a, then navigates /b → /a three times. /a owns
100–103 evaluated memos, representing a bounded change in current page content.
No previous page work is retained in the clean case. All assertions concerning
cleanup and the deliberate leak pass; the final no-diagnostics assertion fails
on stock packages.
Complete repro.ts (no JSX compiler, app code, server, or private APIs)
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<body></body>", { url: "http://localhost/" });
for (const key of [
"window",
"document",
"location",
"history",
"HTMLElement",
"HTMLAnchorElement",
"Node",
"MutationObserver",
"CustomEvent",
]) {
Object.defineProperty(globalThis, key, {
value: Reflect.get(dom.window, key),
configurable: true,
});
}
// Import browser entry points after installing the DOM globals.
const { createRouter, memoryHistory, useNavigate } = await import("@solidjs/router");
const { render } = await import("@solidjs/web");
const { createRoot, createMemo, createSignal, flush, untrack, OBSERVE } = await import("solid-js");
const { graphSize } = await import("solid-js/attribution");
const { captureArtifact } = await import("@solidjs/diagnostics");
if (!OBSERVE) throw new Error("Run with --conditions=browser --conditions=development");
const results = [];
for (const leak of [false, true]) {
const before = graphSize();
const graphs: {
route?: string;
initial: boolean;
roots: number;
owners: number;
computations: number;
signals: number;
edges: number;
}[] = [];
const off = OBSERVE.records.subscribe("graph", (event) => {
graphs.push({
route: event.route,
initial: event.navigation.initial === true,
roots: event.roots,
owners: event.owners,
computations: event.computations,
signals: event.signals,
edges: event.edges,
});
});
const detachedDisposers: (() => void)[] = [];
let disposeTree = () => {};
let navigate: ReturnType<typeof useNavigate> | undefined;
let rowCount = 100;
try {
const { artifact } = await captureArtifact(
() => {
function rows() {
const [value] = createSignal(1);
for (let i = 0; i < rowCount; i++) {
const row = createMemo(() => value() + i);
untrack(row); // Evaluate once; the memo itself tracks value.
}
}
function Page() {
navigate = useNavigate();
rows(); // Owned by this route, disposed on exit.
return document.createTextNode(`Rows: ${rowCount}`);
}
const Router = createRouter({
history: memoryHistory("/a"),
routes: [
{ path: "/a", component: Page },
{ path: "/b", component: () => document.createTextNode("Away") },
],
});
disposeTree = render(() => Router({}), document.body);
// Positive control: a detached root created from imperative code,
// deliberately retained across route exits (but cleaned up after capture).
const retainRoot = () =>
createRoot((dispose) => {
detachedDisposers.push(dispose);
rows();
});
if (leak) retainRoot();
for (const count of [101, 102, 103]) {
navigate!("/b", { scroll: false });
flush();
if (!leak) rowCount = count;
navigate!("/a", { scroll: false });
flush();
if (leak) retainRoot();
}
},
{ scenario: leak ? "undisposed-route-roots" : "bounded-owned-route-content" },
);
results.push({ leak, graphs, diagnostics: artifact.diagnostics });
} finally {
off();
disposeTree();
for (const dispose of detachedDisposers) dispose();
flush();
assert.deepEqual(graphSize(), before, "Both fixtures release all work at teardown");
}
}
dom.window.close();
for (const result of results) {
console.log(result.leak ? "Deliberate leak" : "Fully owned content");
console.table(result.graphs);
console.log(
result.diagnostics.map((event) => ({
code: event.code,
message: event.message,
data: event.data,
})),
);
}
const [owned, leaked] = results;
assert.ok(owned && leaked);
const away = owned.graphs.filter((event) => event.route === "/b");
assert.equal(away.length, 3);
for (const event of away) {
assert.deepEqual(event, away[0], "Every route exit returns to the same live graph");
}
assert.ok(
leaked.diagnostics.some((event) => event.code === "GRAPH_GROWTH"),
"Actual retained roots must still be detected",
);
// Fails on stock RC.13/Router next.33, despite complete cleanup on every exit.
assert.equal(
owned.diagnostics.length,
0,
"A construction-only initial census must not create a false leak verdict",
);
// Router's module-level cache maintenance keeps standalone Node alive.
process.exit(0);
Actual behavior
The clean case emits exactly one diagnostic, GRAPH_GROWTH:
owners 8 → 133 → 134
computations 7 → 123 → 124
edges 13 → 130 → 131
Its live census records show why that verdict is misleading:
| Sample |
Route |
Initial |
Roots |
Owners |
Computations |
Signals |
Edges |
| Router context construction |
/a |
true |
1 |
8 |
7 |
5 |
13 |
| First exit |
/b |
false |
1 |
32 |
22 |
6 |
29 |
| First actual return |
/a |
false |
1 |
133 |
123 |
7 |
130 |
| Second exit |
/b |
false |
1 |
32 |
22 |
6 |
29 |
| Second actual return |
/a |
false |
1 |
134 |
124 |
7 |
131 |
| Third exit |
/b |
false |
1 |
32 |
22 |
6 |
29 |
| Third actual return |
/a |
false |
1 |
135 |
125 |
7 |
132 |
Every /b census is identical; nothing from the previous /a survives.
The three actual return visits are only 123 → 124 → 125 computations, well below
the unchanged default ratio: 1.25. Including the construction-only sample
instead yields 7 → 123 → 124 and reports an undisposed-root leak.
In the deliberate-leak control, roots increase 2 → 3 → 4 across real visits,
and computations at /b increase 122 → 222 → 322 rather than returning to 22.
The detector correctly reports GRAPH_GROWTH there. Both fixtures explicitly
release all their work during teardown, which is also asserted.
Expected behavior
- Initial route declarations remain available to record consumers.
- A pre-content context-construction census is not used as a completed-page-visit
baseline for GRAPH_GROWTH.
- The clean reproduction has zero diagnostics with default options.
- The deliberate retained-root control still reports
GRAPH_GROWTH without
changing thresholds or adding exemptions.
Suspected cause and possible correction
The relevant merged changes are:
Current source reviewed on next still has the interaction:
- Router context construction precedes the returned route tree.
- Signals excludes initial declarations from responsiveness feedback but still calls trackGraph.
- trackGraph emits the census and unconditionally adds its size to visit history.
One narrow correction would retain the raw graph record but exclude initial
declarations from completed-visit comparison history:
records.emit("graph", event, undefined);
-if (cfg === false || route === undefined) return;
+if (cfg === false || route === undefined || navigation.initial === true) return;
Alternatively, a genuinely post-content initial census could serve as the
baseline, but the current construction-time sample is not comparable.
The existing graph-growth tests cover ordinary navigation and flat clean visits;
a regression combining the new initial declaration with small, bounded content
variation would cover this interaction. The deliberate-leak control should remain.
This correction is proposed, not applied or claimed verified. The reproduction
was executed against the published packages; current next was source-reviewed,
not built and executed.
Platform and additional context
- Linux; Node 24.21.0; pnpm 12.8.1; jsdom 30.1.1.
- Published, unpatched Solid/Signals/Web/Diagnostics 2.0.0-rc.13 and Router next.33.
- Reproduced in a fresh standalone installation, independent of application code.
- No customized attribution options, silenced diagnostics, timers, or manual route cleanup.
- Duplicate search found no matching issue in Solid or Router. Router
#643 concerns missing interaction
attribution for anchor/form handlers, not this graph-history baseline.
(submitted by gpt-6.1-sol on behalf of rvlzzr)
Describe the bug
With unpatched Solid
2.0.0-rc.13and Router2.0.0-next.33,GRAPH_GROWTHcanreport retained route work even though every exit releases all of the previous
route's computations and returns to exactly the same live graph.
The initial navigation is a route declaration, emitted around Router context
construction before route content mounts. The graph-growth detector includes
that incomplete census in its history alongside completed navigation visits.
Small, bounded variation in subsequent page content then satisfies the growth
ratio because the first sample lacks the page altogether.
This is about the validity of the first comparison sample, not a request to
raise thresholds or disable leak detection. The reproduction includes a separate
positive control that deliberately retains roots and must still be detected.
Steps to reproduce
Use Node
24.21.0and pnpm12.8.1. In an empty directory, save thispackage.jsonand therepro.tsbelow, then run:{ "private": true, "type": "module", "packageManager": "pnpm@12.8.1", "dependencies": { "@solidjs/diagnostics": "2.0.0-rc.13", "@solidjs/router": "2.0.0-next.33", "@solidjs/web": "2.0.0-rc.13", "jsdom": "30.1.1", "solid-js": "2.0.0-rc.13" } }The fixture mounts
/a, then navigates/b→/athree times./aowns100–103 evaluated memos, representing a bounded change in current page content.
No previous page work is retained in the clean case. All assertions concerning
cleanup and the deliberate leak pass; the final no-diagnostics assertion fails
on stock packages.
Complete repro.ts (no JSX compiler, app code, server, or private APIs)
Actual behavior
The clean case emits exactly one diagnostic,
GRAPH_GROWTH:Its live census records show why that verdict is misleading:
/a/b/a/b/a/b/aEvery
/bcensus is identical; nothing from the previous/asurvives.The three actual return visits are only 123 → 124 → 125 computations, well below
the unchanged default
ratio: 1.25. Including the construction-only sampleinstead yields 7 → 123 → 124 and reports an undisposed-root leak.
In the deliberate-leak control, roots increase 2 → 3 → 4 across real visits,
and computations at
/bincrease 122 → 222 → 322 rather than returning to 22.The detector correctly reports
GRAPH_GROWTHthere. Both fixtures explicitlyrelease all their work during teardown, which is also asserted.
Expected behavior
baseline for
GRAPH_GROWTH.GRAPH_GROWTHwithoutchanging thresholds or adding exemptions.
Suspected cause and possible correction
The relevant merged changes are:
Current source reviewed on
nextstill has the interaction:One narrow correction would retain the raw graph record but exclude initial
declarations from completed-visit comparison history:
records.emit("graph", event, undefined); -if (cfg === false || route === undefined) return; +if (cfg === false || route === undefined || navigation.initial === true) return;Alternatively, a genuinely post-content initial census could serve as the
baseline, but the current construction-time sample is not comparable.
The existing graph-growth tests cover ordinary navigation and flat clean visits;
a regression combining the new initial declaration with small, bounded content
variation would cover this interaction. The deliberate-leak control should remain.
This correction is proposed, not applied or claimed verified. The reproduction
was executed against the published packages; current
nextwas source-reviewed,not built and executed.
Platform and additional context
#643 concerns missing interaction
attribution for anchor/form handlers, not this graph-history baseline.
(submitted by gpt-6.1-sol on behalf of rvlzzr)