Skip to content

rc.9: deferred child recomputation loses REACTIVE_ZOMBIE and leaks subscriptions #3543

Closed

Description

@GabbeV

Describe the bug

An unrelated pending action causes subscriptions from a Show condition to accumulate across ordinary signal updates. They remain after the action finishes.

The reduced case uses two ordinary signals, one action, a sibling binding reading the action-written signal, and:

<Show when={n() > 0 && n() < 2}>Visible</Show>

No optimistic store, router, SSR, effects, or application-managed ownership is involved. The compiler creates a nested memo in the condition getter. Repeatedly alternating n between 0 and 1 should keep the graph bounded.

Your Example Website or App

https://s.olid.uk/?id=9ec31682-7443-42c7-b68e-4020eef4d836

Steps to Reproduce the Bug or Issue

Open the linked playground and click Run reproduction once. It runs a 40-update control, starts an action waiting on a promise, and alternates the other signal until Solid emits its real HUGE_FAN_OUT warning (at most 4,200 updates). It then releases and awaits the action.

Each write is followed by public flush(); the loop yields to the browser every 100 updates. Counts use DEV.getObservers, and the warning is captured through OBSERVE.diagnostics.

Observed with published rc.9:

  • No pending action: 2 subscribers after 40 updates.
  • Pending action: 2,000 subscribers after 3,997 updates, triggering HUGE_FAN_OUT.
  • After the action finishes: still 2,000 subscribers.

Expected behavior

Obsolete computations should be disposed. Repeating the same two signal values should keep the subscriber count bounded, including while an unrelated action is pending and after it finishes.

Screenshots or Videos

No response

Platform

  • Solid packages: 2.0.0-rc.9
  • Development DOM build; published native compiler, generate: "dom", dev: true
  • Chromium on Linux/WSL

Additional context

Runtime trace and causal check

In @solidjs/signals/dist/dev-shared.js from rc.9:

  1. The previous child is marked REACTIVE_ZOMBIE and moved to its owner's _pendingFirstChild; its replacement occupies _firstChild.
  2. While an action is parked, an ambient update can rerun the deferred child via zombieQueue.
  3. recompute drops REACTIVE_ZOMBIE in its flag resets (lines 4272 and 4437); updateIfNecessary also drops it in its final mask (line 4802).
  4. disposeChildren(child, true) relies on this flag to skip the ordinary parent-chain splice (around lines 2730–2741). With the flag lost, disposing the old child writes parent._firstChild = null in this single-child case, detaching the replacement.
  5. The replacement remains subscribed, but future owner cleanup cannot reach it.

An instrumented six-update trace showed the deferred child entering recomputation with flags 42 (including zombie 32), reaching disposal with flags 0, and erasing the current child. The orphaned replacement continued recomputing on subsequent updates.

As a causal experiment, preserving REACTIVE_ZOMBIE through those three flag resets in an isolated, in-memory test bundle keeps the count at 2 subscribers after all 4,200 updates and after action completion, with no warning. This is not a proposed fully validated fix; broader transition/ownership regression coverage is still needed.

Originally noticed as increasing CPU stalls while dragging cards with saves in flight. The app had retained compiler memos under Show condition owners matching this mechanism. The reduced case isolates the leak; it does not claim to quantify the full app's per-gesture amplification.

Activity

  1. added a commit that references this issue on Sep 18, 2026
  2. ryansolid commented on Sep 18, 2026

    @ryansolid
    Member

    Fixed in #3547 (merged to next as 0ba30d3) — thanks for the reproduction and the trace, it pointed straight at the right place.

    Root cause: recompute / updateIfNecessary reset _flags wholesale at the end of a run, which wiped REACTIVE_ZOMBIE. A zombie that re-ran inside a parked transaction came out of the run looking like a live node, so disposeChildren spliced it back into its owner's child chain instead of dropping it — and Show's inner memo kept its subscriptions on the source signal. One leaked observer per pass, exactly what you measured.

    Fix: the three flag resets now preserve REACTIVE_ZOMBIE. Pinned by a signals-level test that mirrors the compiled Show when={...} shape and by a @solidjs/web JSX test built from your repro. It also corrected a stale expectation in the #3463 test — the zombie's re-run was needlessly holding the commit back a frame.

    While tracing this we noticed the owner runs twice under a parked transaction (wasted work, not a leak); that's tracked separately in #3546.

    Ships in rc.10.

    — Claude via Cursor

  3. added a commit that references this issue on Sep 19, 2026
  4. ryansolid commented on Sep 22, 2026

    @ryansolid
    Member

    Fixed on next by #3547 (0ba30d3). It ships with the next rc; reopen if it reproduces there.

    — Claude via Cursor

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