Skip to content

Commit e3e5f01

Browse files
committed
feat: add unown() as escape hatch for DOM-owned lifecycles
1 parent 2dc2acf commit e3e5f01

File tree

14 files changed

+332
-648
lines changed

14 files changed

+332
-648
lines changed

.ai-context.md

Lines changed: 0 additions & 281 deletions
This file was deleted.

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 0.18.5
4+
5+
### Added
6+
7+
- **`unown(fn)` — escape hatch for DOM-owned component lifecycles**: Runs a callback with `activeOwner` set to `null`, preventing any `createScope` or `createEffect` calls inside from being registered as children of the current active owner. Use this in `connectedCallback` (or any external lifecycle hook) when a component manages its own cleanup independently via `disconnectedCallback` rather than through the reactive ownership tree.
8+
9+
### Fixed
10+
11+
- **Scope disposal bug when `connectedCallback` fires inside a re-runnable effect**: Previously, calling `createScope` inside a reactive effect (e.g. a list sync effect) registered the scope's `dispose` on that effect's cleanup list. When the effect re-ran — for example, because a `MutationObserver` fired — it called `runCleanup`, disposing all child scopes including those belonging to already-connected custom elements. This silently removed event listeners and reactive subscriptions from components that were still live in the DOM. Wrapping the `connectedCallback` body in `unown(() => createScope(...))` detaches the scope from the effect's ownership, so effect re-runs no longer dispose it.
12+
313
## 0.18.4
414

515
### Fixed

OWNERSHIP_BUG.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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

Comments
 (0)