|
| 1 | +# Ownership Bug: Component Scope Disposed by Parent Effect |
| 2 | + |
| 3 | +## Symptom |
| 4 | + |
| 5 | +In `module-todo`, `form-checkbox` elements wired via `checkboxes: pass(...)` lose their |
| 6 | +reactive effects after the initial render — `setProperty('checked')` stops updating |
| 7 | +`input.checked`, and the `on('change')` event listener is silently removed. Reading |
| 8 | +`fc.checked` (a pull) still works correctly, but reactive push is gone. |
| 9 | + |
| 10 | +## Root Cause |
| 11 | + |
| 12 | +`createScope` registers its `dispose` on `prevOwner` — the `activeOwner` at the time the |
| 13 | +scope is created. This is the right behavior for *hierarchical component trees* where a |
| 14 | +parent component logically owns its children. But custom elements have a different ownership |
| 15 | +model: **the DOM owns them**, via `connectedCallback` / `disconnectedCallback`. |
| 16 | + |
| 17 | +The problem arises when a custom element's `connectedCallback` fires *inside* a |
| 18 | +re-runnable reactive effect: |
| 19 | + |
| 20 | +1. `module-todo`'s list sync effect runs inside `flush()` with `activeOwner = listSyncEffect`. |
| 21 | +2. `list.append(li)` connects the `<li>`, which connects the `<form-checkbox>` inside it. |
| 22 | +3. `form-checkbox.connectedCallback()` calls `runEffects(ui, setup(ui))`, which calls |
| 23 | + `createScope`. `prevOwner = listSyncEffect`, so `dispose` is **registered on |
| 24 | + `listSyncEffect`**. |
| 25 | +4. Later, the `items = all('li[data-key]')` MutationObserver fires (the DOM mutation from |
| 26 | + step 2 is detected) and re-queues `listSyncEffect`. |
| 27 | +5. `runEffect(listSyncEffect)` calls `runCleanup(listSyncEffect)`, which calls all |
| 28 | + registered cleanups — including `form-checkbox`'s `dispose`. |
| 29 | +6. `dispose()` runs `runCleanup(fc1Scope)`, which removes the `on('change')` event |
| 30 | + listener and trims the `setProperty` effect's reactive subscriptions. |
| 31 | +7. The `<form-checkbox>` elements are still in the DOM, but their effects are permanently |
| 32 | + gone. `connectedCallback` does not re-fire on already-connected elements. |
| 33 | + |
| 34 | +The same problem recurs whenever `listSyncEffect` re-runs for any reason (e.g. a new todo |
| 35 | +is added), disposing the scopes of all existing `<form-checkbox>` elements. |
| 36 | + |
| 37 | +## Why `unown` Is the Correct Fix |
| 38 | + |
| 39 | +`createScope`'s "register on `prevOwner`" semantics model one ownership relationship: |
| 40 | +*parent reactive scope owns child*. Custom elements model a different one: *the DOM owns |
| 41 | +the component*. `disconnectedCallback` is the authoritative cleanup trigger, not the |
| 42 | +reactive graph. |
| 43 | + |
| 44 | +`unown` is the explicit handshake that says "this scope is DOM-owned". It prevents |
| 45 | +`createScope` from registering `dispose` on whatever reactive effect happens to be running |
| 46 | +when `connectedCallback` fires, while leaving `this.#cleanup` + `disconnectedCallback` as |
| 47 | +the sole lifecycle authority. |
| 48 | + |
| 49 | +A `createScope`-only approach (without `unown`) has two failure modes: |
| 50 | + |
| 51 | +| Scenario | Problem | |
| 52 | +|---|---| |
| 53 | +| Connects in static DOM (`activeOwner = null`) | `dispose` is discarded; effects never cleaned up on disconnect — memory leak | |
| 54 | +| Connects inside a re-runnable effect | Same disposal bug as described above | |
| 55 | + |
| 56 | +Per-item scopes (manually tracking a `Map<key, Cleanup>`) could also fix the disposal |
| 57 | +problem but require significant restructuring of the list sync effect and still need |
| 58 | +`unown` to prevent re-registration on each effect re-run. |
| 59 | + |
| 60 | +## Required Changes |
| 61 | + |
| 62 | +### `@zeix/cause-effect` |
| 63 | + |
| 64 | +**`src/graph.ts`** — Add `unown` next to `untrack`: |
| 65 | + |
| 66 | +```typescript |
| 67 | +/** |
| 68 | + * Runs a callback without any active owner. |
| 69 | + * Any scopes or effects created inside the callback will not be registered as |
| 70 | + * children of the current active owner (e.g. a re-runnable effect). Use this |
| 71 | + * when a component or resource manages its own lifecycle independently of the |
| 72 | + * reactive graph. |
| 73 | + * |
| 74 | + * @since 0.18.5 |
| 75 | + */ |
| 76 | +function unown<T>(fn: () => T): T { |
| 77 | + const prev = activeOwner |
| 78 | + activeOwner = null |
| 79 | + try { |
| 80 | + return fn() |
| 81 | + } finally { |
| 82 | + activeOwner = prev |
| 83 | + } |
| 84 | +} |
| 85 | +``` |
| 86 | + |
| 87 | +Export it from the internal graph exports and from **`index.ts`**: |
| 88 | + |
| 89 | +```typescript |
| 90 | +export { |
| 91 | + // ...existing exports... |
| 92 | + unown, |
| 93 | + untrack, |
| 94 | +} from './src/graph' |
| 95 | +``` |
0 commit comments