Skip to content

Commit e1768c1

Browse files
spearwolfclaude
andcommitted
fix(kernel): address KERN-1..7 entity-lifecycle and creation-API bugs
Implement and test the seven HIGH/MEDIUM Backlog items in §3.1/§3.3: - KERN-1: thread `autoDestructionOnParentRemoval` through the change trail (ICreateEntitiesChange field, ComponentChanges.create 4th param, parse()); expose as ViewComponent constructor option, persisted via ComponentMemory. - KERN-2: rebind the auto-destruction subscription on every parent change so re-parented children survive the destruction of their original parent and follow their current one. - KERN-3: destroyEntity now snapshots its children — flagged ones cascade, unflagged ones are promoted to root entries instead of leaking inside the kernel. - KERN-4: invalidate the BFS traversal cache from destroyEntity so programmatic destruction paths (e.g. auto-destroy listeners) no longer return stale UUIDs. - KERN-5: resolve the new parent UUID before detaching the current one so a bad uuid throws without orphaning the entity. - KERN-6: Registry.clear() now also clears prop-based (`@`-prefix) routes. - KERN-7: useProperty/useContext/useParentContext warn when a subsequent call passes a different `{compare}` than the one captured on cache miss. Coverage: - New vitest specs in Kernel.spec.ts, Registry.spec.ts, ViewComponent.spec.ts. - New Playwright E2E `auto-destruct.spec.ts` exercising the real RemoteWorkerEnv: a parent SO programmatically creates a flagged + an unflagged child via entity.kernel.createEntity; after destroyEntity on their shared root, the flagged child cascades and the unflagged one is promoted to root. Runs in Chromium and Firefox. CHANGELOG.md and Backlog.md are updated accordingly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e9c4d49 commit e1768c1

17 files changed

Lines changed: 707 additions & 40 deletions

Backlog.md

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636

3737
**Hauptrisiken:**
3838
1. **Worker-Fehlerpfade unter-implementiert** — keine `error`/`messageerror`-Handler, keine Reconnect-Logik, ausstehende Promises hängen nach Worker-Tod ewig (oder bis 5–60 s Timeout).
39-
2. **Neues Feature „auto destruction on parent removal" (Commit 89c59c2) ist im Datenpfad nicht erreichbar** und behandelt Re-Parenting nicht.
40-
3. **`destroyEntity` rekursiert nicht über Kinder** — bei Eltern-Destruktion bleiben Nicht-Auto-Kinder als verwaiste Einträge im Kernel.
39+
2. ~~**Neues Feature „auto destruction on parent removal" (Commit 89c59c2) ist im Datenpfad nicht erreichbar** und behandelt Re-Parenting nicht.~~ **Behoben (KERN-1, KERN-2)** — Flag fließt jetzt durch `ICreateEntitiesChange``ComponentChanges.create()``parse()`; Subscription wird bei Re-Parent neu verdrahtet.
40+
3. ~~**`destroyEntity` rekursiert nicht über Kinder** — bei Eltern-Destruktion bleiben Nicht-Auto-Kinder als verwaiste Einträge im Kernel.~~ **Behoben (KERN-3)** — Variante C: Flagged-Kinder kaskadieren, ungeflaggte werden zu Roots befördert.
4141
4. **DOM-In-Place-Re-Parenting wird nicht beobachtet** (`<shae-prop>` und `<shae-ent>` resolvieren ihren Eltern-Knoten nur in `connectedCallback`).
4242
5. **CI lässt das gesamte E2E-Paket aus** — der Worker-Roundtrip wird damit faktisch nicht von CI verifiziert.
4343
6. **`MessageRouter` schluckt Fehler** durch doppeltes `AppliedChangeTrail` im Catch-Pfad — Konsumenten sehen Erfolg trotz interner Exception.
@@ -112,23 +112,13 @@ ComponentContext│ ─ Destroy
112112
113113
### 3.1 HIGH — Kern (in-the-dark)
114114

115-
**[KERN-1]** `autoDestructionOnParentRemoval` ist im Change-Trail-Datenpfad nicht erreichbar.
116-
*Ort:* `Kernel.ts:218–249`, `types.ts:24–30`.
117-
`createEntity` akzeptiert den 6. Parameter, aber `parse()` (Zeile 185) gibt ihn nie weiter, und `ICreateEntitiesChange` enthält das Feld gar nicht. Nur der Unit-Test erreicht den Pfad — in Produktion ist das Feature ein No-Op.
118-
*Empfehlung:* Feld zu `ICreateEntitiesChange` ergänzen, in `ComponentChanges.create()` einreihen, `parse()` weiterreichen, E2E-Test mit echtem Worker ergänzen.
115+
**[KERN-1]** ~~`autoDestructionOnParentRemoval` ist im Change-Trail-Datenpfad nicht erreichbar.~~ **✅ Behoben** — Feld in `ICreateEntitiesChange` ergänzt, durch `ComponentChanges.create()` (4. Parameter) und `Kernel.parse()` an `createEntity()` weitergereicht. `ViewComponent` bekommt eine neue Konstruktoroption `autoDestructionOnParentRemoval`. Abgedeckt von vitest-Specs (Kernel + ViewComponent) sowie Playwright-E2E-Test `auto-destruct.spec.ts` mit echtem `RemoteWorkerEnv` (Chromium + Firefox).
119116

120-
**[KERN-2]** Re-Parenting bricht die Auto-Destruktions-Subscription.
121-
*Ort:* `Entity.ts:209–227, 235–246`, `Kernel.ts:265–289`.
122-
`removeFromParent()` löscht `#parent`, lässt aber `#autoDestructionSubscription` auf den alten Eltern-Knoten zeigen. Wird der alte Vater später zerstört, stirbt das längst woanders einsortierte Kind mit. Umgekehrt wird der neue Vater nicht „verheiratet".
117+
**[KERN-2]** ~~Re-Parenting bricht die Auto-Destruktions-Subscription.~~ **✅ Behoben**`Entity` trennt jetzt User-Intent (`#autoDestructionEnabled`) von der konkreten Subscription. `set parentUuid` und `removeFromParent()` rufen `#updateAutoDestructionSubscription()`, das die Subscription gegen den jeweils aktuellen Vater neu herstellt.
123118

124-
**[KERN-3]** `destroyEntity` rekursiert nicht über Kinder.
125-
*Ort:* `Kernel.ts:251–263`, `Entity.ts:142–169, 167–168`.
126-
Eltern-`destroyEntity` löscht nur die explizite UUID. Kinder ohne `autoDestructionOnParentRemoval` bleiben als verwaiste Einträge in `kernel.#entities`/`#rootEntities` zurück → echter Leak. Die `#children`-Liste des Vaters wird via `length = 0` geleert, aber die Kinder selbst nicht abgeräumt.
127-
*Empfehlung:* `destroyEntity` muss Kinder rekursiv abräumen (oder `removeFromParent` sie zu Root befördern), ggf. Policy einbauen.
119+
**[KERN-3]** ~~`destroyEntity` rekursiert nicht über Kinder.~~ **✅ Behoben (Variante C)**`Kernel.destroyEntity()` snapshottet die Kinderliste; flagged Kinder werden rekursiv abgeräumt, ungeflaggte via `removeFromParent()` zu Root befördert (und in `#rootEntities` aufgenommen). Damit ist der Leak in `kernel.#entities` geschlossen.
128120

129-
**[KERN-4]** Cache-Invalidierung in `traverseLevelOrderBFS` greift bei programmatischer Destruktion nicht.
130-
*Ort:* `Kernel.ts:84, 191/197/202`, sowie der Auto-Destroy-Pfad `Entity.ts:239`.
131-
`#allEntitiesNeedUpdate` wird nur in `parse()` gesetzt, nicht wenn Kinder direkt via Auto-Destroy-Listener `kernel.destroyEntity` aufrufen. Folge: nachfolgende Traversal-Aufrufe können stale UUIDs zurückgeben.
121+
**[KERN-4]** ~~Cache-Invalidierung in `traverseLevelOrderBFS` greift bei programmatischer Destruktion nicht.~~ **✅ Behoben**`#allEntitiesNeedUpdate` wird jetzt direkt in `destroyEntity()` gesetzt, sodass auch Auto-Destroy-Listener-Pfade den BFS-Cache invalidieren.
132122

133123
### 3.2 HIGH — View / Worker
134124

@@ -160,9 +150,9 @@ Wenn ein `<shae-ent>`-Vater aus dem DOM entfernt wird, das Kind aber selbst noch
160150
| **VIEW-9** | `removeTransferables` mutiert die Caller-Trail-Einträge per `delete`. | `RemoteWorkerEnv.ts:23–40` |
161151
| **VIEW-10** | `LocalShadowObjectEnv` ignoriert `waitForConfirmation`; `MessageToView` läuft via `queueMicrotask`, sodass der `AfterSync`-Event vor den Worker-Nachrichten feuert. **Verhaltensasymmetrie zu `RemoteWorkerEnv`.** | `LocalShadowObjectEnv.ts:40`, `Kernel.ts:316–319` |
162152
| **VIEW-11** | `ShaePropElement.isShaeEntElement = true` ist offenbar Copy-Paste — nutzt aber `findEntNode` zur Eltern-Suche, was potenziell falsche Treffer ergibt. | `ShaePropElement.ts:68` |
163-
| **KERN-5** | `Entity.parentUuid`-Setter ruft `removeFromParent()` *vor* dem Resolven des neuen Vaters; wirft `getEntity` einen Fehler, ist die Entity verwaist. | `Entity.ts:97–108` |
164-
| **KERN-6** | `Registry.clear()` löscht `#truthyPropRoutes` nicht — Test-Pollution + Akkumulation in langlebigen Registries. | `Registry.ts:139–142` |
165-
| **KERN-7** | `useContext`/`useParentContext`/`useProperty` ignorieren `options` bei Cache-Hit (z. B. `compare`). Der erste Aufrufer „gewinnt", was leise zu falschen Equality-Vergleichen führen kann. | `Kernel.ts:387–396, 495–504, 517–525` |
153+
| ~~**KERN-5**~~ | ~~`Entity.parentUuid`-Setter ruft `removeFromParent()` *vor* dem Resolven des neuen Vaters; wirft `getEntity` einen Fehler, ist die Entity verwaist.~~ **✅ Behoben**`getEntity` wird *vor* dem Detach aufgerufen; `Kernel.setParent` validiert die neue UUID vorab. | `Entity.ts`, `Kernel.ts` |
154+
| ~~**KERN-6**~~ | ~~`Registry.clear()` löscht `#truthyPropRoutes` nicht — Test-Pollution + Akkumulation in langlebigen Registries.~~ **✅ Behoben**`clear()` räumt auch die Prop-basierten Routen ab. | `Registry.ts` |
155+
| ~~**KERN-7**~~ | ~~`useContext`/`useParentContext`/`useProperty` ignorieren `options` bei Cache-Hit (z. B. `compare`). Der erste Aufrufer „gewinnt", was leise zu falschen Equality-Vergleichen führen kann.~~ **✅ Behoben** — bei Cache-Hit mit abweichender `compare`-Funktion wird ein `console.warn` emittiert; das alte Reader-Objekt bleibt aus Kompatibilitätsgründen erhalten. | `Kernel.ts` |
166156
| **VIEW-12** | `ShaePropElement` parst numerische Attribute ohne Warnung — `Number("foo")``NaN` propagiert. | `ShaePropElement.ts:177–205` |
167157
| **VIEW-13** | `ShaeEntElement.#dispatchRequestParent`-Microtask prüft `isConnected` nicht; nach Disconnect bubbelt ein Streu-Event. | `ShaeEntElement.ts:343–346` |
168158

@@ -321,12 +311,12 @@ Ein reines JS-Paket (kein TS), `src/` wird ohne Bundle-Schritt veröffentlicht.
321311

322312
### 7.1 Muss vor 1.0
323313

324-
1. **Auto-Destroy-Feature komplett verdrahten** — Feld in `ICreateEntitiesChange`, durchreichen in `parse()`, Re-Parenting-Subscription pflegen, E2E-Test mit Worker. *(KERN-1, KERN-2)*
325-
2. **`destroyEntity` rekursiv über Kinder** — Politik definieren (kaskadieren oder zu Root befördern). *(KERN-3)*
314+
1. ~~**Auto-Destroy-Feature komplett verdrahten** — Feld in `ICreateEntitiesChange`, durchreichen in `parse()`, Re-Parenting-Subscription pflegen, E2E-Test mit Worker. *(KERN-1, KERN-2)*~~ ✅ Erledigt (Kernel- und ViewComponent-Specs sowie Playwright-E2E `auto-destruct.spec.ts` mit echtem `RemoteWorkerEnv`).
315+
2. ~~**`destroyEntity` rekursiv über Kinder** — Politik definieren (kaskadieren oder zu Root befördern). *(KERN-3)*~~ ✅ Erledigt (Variante C).
326316
3. **`MessageRouter`-Doppel-Confirm im Catch-Pfad fixen.** *(VIEW-3)*
327317
4. **Worker-Fehlerpfade härten:** `error`/`messageerror`-Handler, ausstehende Promises bei `destroy()` rejecten, expliziter `terminated`-Status. *(VIEW-1, VIEW-2)*
328318
5. **CI lässt E2E nicht aus** — Playwright-Browser im CI-Image installieren, `test:ci` umstellen oder zweiten Job ergänzen. *(CI-Gap)*
329-
6. **Cache-Invalidierung von `traverseLevelOrderBFS` bei programmatischer Destruktion.** *(KERN-4)*
319+
6. ~~**Cache-Invalidierung von `traverseLevelOrderBFS` bei programmatischer Destruktion.** *(KERN-4)*~~ ✅ Erledigt.
330320

331321
### 7.2 Sollte zeitnah
332322

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>auto-destruct (KERN-1)</title>
8+
</head>
9+
<body>
10+
<section id="tests"></section>
11+
<script type="module" src="/src/auto-destruct.js"></script>
12+
</body>
13+
</html>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Module-level lifecycle tracker — children's onDestroy callbacks update this,
2+
// the parent SO reads it back and reports to the view.
3+
const lifecycle = {
4+
flagged: {created: false, destroyed: false, uuid: null},
5+
unflagged: {created: false, destroyed: false, uuid: null},
6+
};
7+
8+
function flaggedChild({entity, onDestroy}) {
9+
lifecycle.flagged.created = true;
10+
lifecycle.flagged.uuid = entity.uuid;
11+
onDestroy(() => {
12+
lifecycle.flagged.destroyed = true;
13+
});
14+
}
15+
16+
function unflaggedChild({entity, onDestroy}) {
17+
lifecycle.unflagged.created = true;
18+
lifecycle.unflagged.uuid = entity.uuid;
19+
onDestroy(() => {
20+
lifecycle.unflagged.destroyed = true;
21+
});
22+
}
23+
24+
function parent({entity, dispatchMessageToView}) {
25+
const doomedUuid = crypto.randomUUID();
26+
const flaggedUuid = crypto.randomUUID();
27+
const unflaggedUuid = crypto.randomUUID();
28+
29+
// Build a doomed subtree under the (live) parent entity:
30+
// parent
31+
// └── doomedRoot
32+
// ├── flagged (autoDestructionOnParentRemoval=true)
33+
// └── unflagged (autoDestructionOnParentRemoval=false)
34+
entity.kernel.createEntity(doomedUuid, '#void', entity.uuid);
35+
entity.kernel.createEntity(flaggedUuid, 'flaggedChild', doomedUuid, 0, undefined, true);
36+
entity.kernel.createEntity(unflaggedUuid, 'unflaggedChild', doomedUuid, 0, undefined, false);
37+
38+
const beforeDestroy = {
39+
flaggedCreated: lifecycle.flagged.created,
40+
unflaggedCreated: lifecycle.unflagged.created,
41+
flaggedAlive: entity.kernel.hasEntity(flaggedUuid),
42+
unflaggedAlive: entity.kernel.hasEntity(unflaggedUuid),
43+
};
44+
45+
entity.kernel.destroyEntity(doomedUuid);
46+
47+
const afterDestroy = {
48+
flaggedDestroyed: lifecycle.flagged.destroyed,
49+
unflaggedDestroyed: lifecycle.unflagged.destroyed,
50+
flaggedAlive: entity.kernel.hasEntity(flaggedUuid),
51+
unflaggedAlive: entity.kernel.hasEntity(unflaggedUuid),
52+
unflaggedHasParent: entity.kernel.hasEntity(unflaggedUuid) ? entity.kernel.getEntity(unflaggedUuid).hasParent : null,
53+
};
54+
55+
dispatchMessageToView('autoDestructResult', {beforeDestroy, afterDestroy});
56+
}
57+
58+
export const shadowObjects = {
59+
define: {
60+
parent,
61+
flaggedChild,
62+
unflaggedChild,
63+
},
64+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {on, onceAsync} from '@spearwolf/eventize';
2+
import {ComponentContext, RemoteWorkerEnv, ShadowEnv, ViewComponent} from '@spearwolf/shadow-objects';
3+
import './style.css';
4+
import {testAsyncAction} from './test-helpers/testAsyncAction.js';
5+
import {testBooleanAction} from './test-helpers/testBooleanAction.js';
6+
7+
main();
8+
9+
async function main() {
10+
const shadowEnv = new ShadowEnv();
11+
shadowEnv.view = ComponentContext.get();
12+
shadowEnv.envProxy = new RemoteWorkerEnv();
13+
14+
await testAsyncAction('auto-destruct-env-ready', async () => {
15+
await shadowEnv.ready();
16+
});
17+
18+
await testAsyncAction('auto-destruct-import-module', async () => {
19+
await shadowEnv.envProxy.importScript('/mod-auto-destruct.js');
20+
});
21+
22+
const parentVC = new ViewComponent('parent');
23+
24+
let result;
25+
on(parentVC, 'autoDestructResult', (payload) => {
26+
result = payload;
27+
});
28+
29+
await testAsyncAction('auto-destruct-result-arrived', async () => {
30+
await shadowEnv.sync();
31+
await onceAsync(parentVC, 'autoDestructResult');
32+
});
33+
34+
testBooleanAction('auto-destruct-children-were-created', () => {
35+
return (
36+
result?.beforeDestroy?.flaggedCreated === true &&
37+
result?.beforeDestroy?.unflaggedCreated === true &&
38+
result?.beforeDestroy?.flaggedAlive === true &&
39+
result?.beforeDestroy?.unflaggedAlive === true
40+
);
41+
});
42+
43+
testBooleanAction('auto-destruct-flagged-child-cascaded', () => {
44+
return result?.afterDestroy?.flaggedDestroyed === true && result?.afterDestroy?.flaggedAlive === false;
45+
});
46+
47+
testBooleanAction('auto-destruct-unflagged-child-promoted-to-root', () => {
48+
return (
49+
result?.afterDestroy?.unflaggedDestroyed === false &&
50+
result?.afterDestroy?.unflaggedAlive === true &&
51+
result?.afterDestroy?.unflaggedHasParent === false
52+
);
53+
});
54+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {expect, test} from '@playwright/test';
2+
3+
const lookupTestResult = (testId: string) => {
4+
test(testId, async ({page}) => {
5+
await expect(page.getByTestId(testId)).toHaveAttribute('data-testresult', 'ok');
6+
});
7+
};
8+
9+
const lookupTests = (testIds: string[]) => testIds.forEach(lookupTestResult);
10+
11+
test.describe('auto-destruct (KERN-1)', () => {
12+
test.beforeEach('goto page', async ({page}) => {
13+
await page.goto('/pages/auto-destruct.html');
14+
});
15+
16+
lookupTests([
17+
'auto-destruct-env-ready',
18+
'auto-destruct-import-module',
19+
'auto-destruct-result-arrived',
20+
'auto-destruct-children-were-created',
21+
'auto-destruct-flagged-child-cascaded',
22+
'auto-destruct-unflagged-child-promoted-to-root',
23+
]);
24+
});

packages/shadow-objects/CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
1010
## [Unreleased]
1111

12+
- **Bugfix (entity lifecycle, KERN-3):** `kernel.destroyEntity()` now handles its children explicitly instead of leaving them as orphaned entries inside the kernel. Children with `autoDestructionOnParentRemoval` cascade-destroy together with the parent; all other children are promoted to root entities and remain reachable. This fixes a real leak where children without the auto-destruct flag were left in `kernel.#entities` after the parent was destroyed.
13+
- **Bugfix (entity lifecycle, KERN-2):** `autoDestructionOnParentRemoval` now survives re-parenting. The subscription is rebound to the new parent on every parent change (`Entity.parentUuid` setter / `Kernel.setParent()`), so a re-parented child is no longer destroyed when its *original* parent dies, and is correctly destroyed with its *current* parent.
14+
- **Bugfix (change trail, KERN-1):** the `autoDestructionOnParentRemoval` flag is now carried end-to-end through the change-trail pipeline. `ICreateEntitiesChange` exposes a new optional `autoDestructionOnParentRemoval?: boolean` field, `ComponentChanges.create()` accepts it as a 4th parameter, and `Kernel.run()`/`parse()` forwards it to `createEntity()`. Previously the kernel parameter existed but the trail-based path (worker and local env) never set it, so the feature was unreachable in production.
15+
- **New (public API):** `ViewComponent` accepts an `autoDestructionOnParentRemoval?: boolean` constructor option (also exposed as a read-only getter); the value flows through `ComponentContext.addComponent()` into the change trail, and survives `ContextLost` recovery via `ComponentMemory`. Backwards-compatible (defaults to `false`).
16+
- **Bugfix (entity lifecycle, KERN-5):** `Entity.parentUuid` setter and `Kernel.setParent()` now resolve the new parent UUID *before* detaching from the current parent. A `setParent` call with an unknown UUID throws as before but the entity stays attached to its original parent instead of being orphaned mid-mutation.
17+
- **Bugfix (registry, KERN-6):** `Registry.clear()` now also clears the prop-based (`@`-prefix) routes. Previously they accumulated across `clear()` calls, polluting tests and long-lived registries.
18+
- **DX (creation API, KERN-7):** `useProperty()`, `useContext()`, and `useParentContext()` now warn when a subsequent call passes a different `{compare}` function than the first call. The cached signal is created once with the original options; subsequent calls silently returned the cached reader, which could lead to surprising equality semantics. New behavior: still returns the cached reader, but emits a `console.warn` so the mismatch is visible.
19+
- **Bugfix (BFS cache, KERN-4):** `kernel.destroyEntity()` now invalidates the BFS traversal cache. Previously, programmatic destruction (e.g. through an auto-destroy listener) could leave `traverseLevelOrderBFS()` returning stale UUIDs.
1220
- **Bugfix (`./bundle.js` export):** the published `dist/bundle.js` now actually contains the inlined worker and the shae-element registrations. Previous releases shipped a 790-byte stub that only set `globalThis.SHADOW_ENTS_BUNDLE_LOADED = true` because the source-side `package.json#sideEffects` array referenced the (no-longer-emitted) intermediate `build/src/*` paths and the bundle entry was tree-shaken. Consumers using `import '@spearwolf/shadow-objects/bundle.js'` now get the full bundle (~130 KB).
1321
- **Cleanup:** the published `dist/` no longer contains the leftover `tsconfig.lib.tsbuildinfo` build artifact.
14-
- No source, runtime-API, or runtime-dependency changes. `@spearwolf/eventize@^4.3.1` and `@spearwolf/signalize@^0.28.0` unchanged.
22+
- Runtime dependencies updated: `@spearwolf/eventize@^4.3.1`, `@spearwolf/signalize@^0.28.0`.
1523

1624
## [0.30.2] - 2026-02-26
1725

0 commit comments

Comments
 (0)