Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/graph-growth-outlived-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

`GRAPH_GROWTH` no longer reports a route whose correctly owned content is bigger on each visit (`/rows/1`, `/rows/3`, `/rows/5`) when leaving it disposes everything. While the check is on, each non-initial navigation also counts what outlived it — the nodes alive when it opened that are still alive at its settle — and a series reports only when it climbs both in the totals, as before, and in that count. A navigation that stays on the route pattern (only params or query change) continues the visit and is not compared. The finding's `data` gains `survived` beside `history`, and its message shows both (`owners 3 → 5 → 7 (of 6 → 10 → 14)`). Fixes #4007.
10 changes: 5 additions & 5 deletions documentation/solid-2.0/08-dev-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,13 +428,13 @@ Related: `WIDE_SCOPE_DEPS` (below) fires at a lower threshold, but only while th

#### `GRAPH_GROWTH`

**Message:** "the live graph grew on 3 consecutive visits to `/orders`: computations 900 → 903 → 906; edges 900 → 903 → 906 (2 roots), across visits to `/orders`, `/`. Each visit left something behind that the next did not reclaim — the shape says an effect or memo created with no owner (a module-level or callback `createEffect`) that only its sources keep alive. Dispose what a visit creates (`onCleanup`, or return the disposer from `onSettled`) and own it under the route's component so leaving the route tears it down."
**Message:** "the live graph grew on 3 consecutive visits to `/orders`, and so did the part that outlived each navigation there: computations 880 → 883 → 886 (of 900 → 903 → 906); edges 880 → 883 → 886 (of 900 → 903 → 906) (2 roots), across visits to `/orders`, `/`. Each visit left something behind that the next did not reclaim — the shape says an effect or memo created with no owner (a module-level or callback `createEffect`) that only its sources keep alive. Dispose what a visit creates (`onCleanup`, or return the disposer from `onSettled`) and own it under the route's component so leaving the route tears it down."

Attribution-engine only; the leak class a heap snapshot finds, as a finding. At every navigation's settle (`withOrigin({ kind: "navigation" })`) the engine measures the live graph with a **walk**, never a per-node counter: the owner tree from the registered top-level roots (`owners`: roots, component owners, owned computations), then everything reachable from it through the reactive graph — each computation's dependency links (`edges`) and the `signals` and computations they reach, and each reached node's subscriber list, which is how a computation **no owner holds** (a `createEffect` with no owner, kept alive only by the sources it reads) is found and counted in `computations`. It emits a `graph` record (`GraphEvent`: the `GraphSize` fields plus `at`, `route`, `navigation`) for an `OBSERVE.records.subscribe("graph", …)` listener, and keeps the size at each settle of the same route. When any series — `owners`, `computations`, `signals`, `edges` — has climbed on `graphGrowth.visits` consecutive visits (default 3) to `ratio` or more of the first (default 1.25), the route reports, and _which_ series climbed names the leak: `owners` is an undisposed root or a Portal per visit; `computations` with `owners` flat is an ownerless effect; `edges` alone is a subscription per visit to something long-lived. The count is the whole graph's, so a leak shows at every route's settle: the first route to complete its climb reports and `data.routes` names the others seen; the verdict then resets. `data`: `route`, `grew` (the series that climbed), `history` (the `GraphSize` at each settle, oldest first), `roots`, `routes`, `interaction`. No subject.
Attribution-engine only; the leak class a heap snapshot finds, as a finding. At every navigation's settle (`withOrigin({ kind: "navigation" })`) the engine measures the live graph with a **walk**, never a per-node counter: the owner tree from the registered top-level roots (`owners`: roots, component owners, owned computations), then everything reachable from it through the reactive graph — each computation's dependency links (`edges`) and the `signals` and computations they reach, and each reached node's subscriber list, which is how a computation **no owner holds** (a `createEffect` with no owner, kept alive only by the sources it reads) is found and counted in `computations`. It emits a `graph` record (`GraphEvent`: the `GraphSize` fields plus `at`, `route`, `navigation`) for an `OBSERVE.records.subscribe("graph", …)` listener, and keeps the size at each settle of the same route. A count taken at an arrival also holds everything that visit just created, so a page whose correctly owned content is bigger on each visit (`/rows/1`, `/rows/3`, `/rows/5`) climbs too, although leaving it disposes everything. So the engine also counts what **outlived** each navigation: while the check is on, a non-initial navigation walks the graph once more as it opens, adding every node it meets to a `WeakSet`, and its settle's walk counts the nodes from that set still alive (an edge counts when both its ends are), then drops the set. A visit's own content is not among them; a root, an ownerless effect or a retained signal an earlier visit left behind is, whatever the content does. When any series — `owners`, `computations`, `signals`, `edges` — has climbed on `graphGrowth.visits` consecutive visits (default 3) to `ratio` or more of the first (default 1.25), both in the totals and in what outlived each navigation, the route reports, and _which_ series climbed names the leak: `owners` is an undisposed root or a Portal per visit; `computations` with `owners` flat is an ownerless effect; `edges` alone is a subscription per visit to something long-lived. The count is the whole graph's, so a leak shows at every route's settle: the first route to complete its climb reports and `data.routes` names the others seen; the verdict then resets. `data`: `route`, `grew` (the series that climbed), `history` (the `GraphSize` at each settle, oldest first), `survived` (the `GraphSize` of what outlived each of those navigations — alive when it opened and still alive at its settle — beside `history`), `roots`, `routes`, `interaction`. No subject.

Navigation declarations with `initial: true` still emit a `graph` record but do not count toward `GRAPH_GROWTH` history. The initial declaration runs before route content mounts, so its graph size cannot be compared with completed visits. Later visits to that route count as usual.
Navigation declarations with `initial: true` still emit a `graph` record but do not count toward `GRAPH_GROWTH` history. The initial declaration runs before route content mounts, so its graph size cannot be compared with completed visits. Later visits to that route count as usual. A navigation that stays on the route pattern the app is on — only its params or query change (`/users/1` → `/users/2` under `/users/:id`) — continues that visit and is not compared either: the page it kept outlived the move by design, so counting it would read the page itself as growth. Only arrivals that enter a route are compared; a leak made during a visit is still alive when the next visit starts, so it still shows there. A navigation that was superseded never showed, so it does not change the route the app is on.

The observe core's part is the root registry: `createOwner` with no parent registers the root (weakly — a `WeakRef`, reaped by a `FinalizationRegistry` — so an undisposed root nothing references still collects; one a subscription keeps alive is exactly what the walk counts), and its disposal unregisters it. One Set write per top-level root, nothing per node; the walk runs at navigation cadence, only when the check is on or something listens for `graph` records. Measured on the observe artifacts (components of 1 signal, 3 memos, 6 effects): 10k owners walk in 0.6ms, 50k in 3.6ms — a `Set` for the reached nodes is most of it. A reactive node the app's own graph never reaches (an ownerless effect over a signal nothing owned reads) is not counted. `graphSize()` is exported from `solid-js/attribution` for a consumer that wants the measure on its own schedule. `false` disables.
The observe core's part is the root registry: `createOwner` with no parent registers the root (weakly — a `WeakRef`, reaped by a `FinalizationRegistry` — so an undisposed root nothing references still collects; one a subscription keeps alive is exactly what the walk counts), and its disposal unregisters it. One Set write per top-level root, nothing per node; the walk runs at navigation cadence, only when the check is on or something listens for `graph` records — twice per navigation while the check is on (as it opens and at its settle). Measured on the observe artifacts (components of 1 signal, 3 memos, 6 effects): 10k owners walk in 0.6ms, 50k in 3.6ms — a `Set` for the reached nodes is most of it. A reactive node the app's own graph never reaches (an ownerless effect over a signal nothing owned reads) is not counted. `graphSize()` is exported from `solid-js/attribution` for a consumer that wants the measure on its own schedule. `false` disables.

#### `HOT_SCOPE_RERUNS`, `HOT_SCOPE_TIME`, `WIDE_SCOPE_DEPS`

Expand Down Expand Up @@ -952,7 +952,7 @@ The runtime derives a request's trace itself in every tier — the W3C `tracepar
| `FLUSH_IN_EFFECT_CALLBACK` | warn | lifecycle | `flush()` from an effect callback (no-op; the drain is already running) |
| `HUGE_FAN_OUT` | warn | graph | One change reached 2000 live subscribers (always on), or 250 — `fanOut` — on a stamped root write (attribution enabled) |
| `HUGE_FAN_IN` | warn | graph | One recompute tracked 2000 sources (always on) |
| `GRAPH_GROWTH` | warn | perf | The live owner count at the same route's settle climbed on 3 consecutive visits to 1.25× — something each visit leaves behind (attribution enabled) |
| `GRAPH_GROWTH` | warn | perf | The live graph and what outlived each navigation climbed at a route's settle on 3 visits to 1.25× — something each visit leaves behind (attribution enabled) |
| `HOT_SCOPE_RERUNS` | warn | perf | 120+ re-runs of one scope in 1s (attribution enabled) |
| `HOT_SCOPE_FANOUT` | warn | perf | 5+/50+/500+ scopes hot from one root cause (attribution enabled) |
| `HOT_SCOPE_TIME` | warn | perf | 8ms+ self-time in one scope in 1s (attribution enabled) |
Expand Down
Loading
Loading