Skip to content

[2.0.0-rc.13] GRAPH_GROWTH treats the pre-content initial route declaration as a completed visit #3735

Description

@rvlzzr

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:

  1. Router context construction precedes the returned route tree.
  2. Signals excludes initial declarations from responsiveness feedback but still calls trackGraph.
  3. 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions