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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "data-monorepo",
"version": "0.10.10",
"version": "0.10.11",
"private": true,
"engines": {
"node": ">=24"
Expand Down
2 changes: 1 addition & 1 deletion packages/data-ai/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "adobe-data-ai",
"version": "0.10.10",
"version": "0.10.11",
"description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.",
"author": {
"name": "Adobe"
Expand Down
2 changes: 1 addition & 1 deletion packages/data-ai/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-ai",
"version": "0.10.10",
"version": "0.10.11",
"description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).",
"type": "module",
"private": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/data-gpu/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-gpu",
"version": "0.10.10",
"version": "0.10.11",
"description": "Adobe data WebGPU plugins and types for graphics and compute",
"type": "module",
"private": false,
Expand Down
20 changes: 19 additions & 1 deletion packages/data-lit/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# @adobe/data-lit

Lit bindings for [@adobe/data](https://www.npmjs.com/package/@adobe/data) — hooks, elements, and decorators for building reactive UIs with the @adobe/data ECS database and observables.
Lit bindings for [@adobe/data](https://www.npmjs.com/package/@adobe/data): hooks, elements, and decorators
for building reactive UIs with the @adobe/data ECS database and observables.

## Install

Expand All @@ -15,4 +16,21 @@ import { ApplicationHost, DatabaseElement } from "@adobe/data-lit";
import { createDatabase } from "@adobe/data/ecs";
```

## Hooks and the disconnect lifecycle

Hooks (`useState`, `useEffect`, `useObservable`, `useObservableValues`, `useMemo`, `useRef`, ...) follow
React's unmount semantics. When a hook-using element is disconnected from the DOM, **all of its hook state is
reset**:

- every `useEffect` cleanup runs, so observable subscriptions and event listeners are torn down, and
- all stored values (`useState`, `useMemo`, `useRef`) are discarded.

On the next connect the element renders fresh from its initial hook state.

> **Footgun:** because a disconnect is a full reset, a DOM *move* (`el.remove()` then re-appending it, or
> reparenting the element) is treated as a full unmount followed by a remount. Any local UI state held in
> hooks is silently lost across the move: toggle positions, uncontrolled input values, scroll offsets. If a
> piece of state must survive a reparent, keep it in the `@adobe/data` database/service rather than in element
> hook state.

See the `data-lit-todo` sample in this repository for a full example.
3 changes: 2 additions & 1 deletion packages/data-lit/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@adobe/data-lit",
"version": "0.10.10",
"version": "0.10.11",
"description": "Adobe data Lit bindings - hooks, elements, decorators",
"type": "module",
"private": false,
Expand All @@ -25,6 +25,7 @@
"lit": "^3.3.1"
},
"devDependencies": {
"happy-dom": "^20.14.0",
"lit": "^3.3.1",
"typescript": "^5.8.3",
"vitest": "^1.6.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
// @vitest-environment happy-dom
import { describe, it, expect } from "vitest";
import { LitElement, html } from "lit";
import type { Observe } from "@adobe/data/observe";
import { attachDecorator } from "../attach-decorator.js";
import { withHooks } from "../with-hooks.js";
import { useObservable } from "../use-observable.js";

/**
* Real-DOM regression test for the hook-disposal fix. Where the sibling
* hooks-controller.test.ts drives a hand-rolled FakeHost, this mounts a genuine
* LitElement in a happy-dom document, so it exercises the actual bug path:
* `withHooks` installs the controller, `addController` fires `hostConnected`
* synchronously during the real connect, and a real `useObservable` subscription
* is torn down when the element leaves the DOM (instead of leaking and firing
* `requestUpdate` on a detached element).
*/

/** An Observe whose live subscriber count the test can inspect. */
function createTrackedObservable(): {
observable: Observe<number>;
emit: (value: number) => void;
subscriberCount: () => number;
} {
const subscribers = new Set<(value: number) => void>();
let current = 0;
const observable: Observe<number> = observer => {
subscribers.add(observer);
observer(current);
return () => {
subscribers.delete(observer);
};
};
return {
observable,
emit: value => {
current = value;
for (const observer of subscribers) observer(value);
},
subscriberCount: () => subscribers.size,
};
}

class HookProbeElement extends LitElement {
observable!: Observe<number>;
constructor() {
super();
attachDecorator(this, "render", withHooks);
}
render() {
const value = useObservable(this.observable);
return html`<span>${value ?? ""}</span>`;
}
}
customElements.define("hook-probe-element", HookProbeElement);

describe("hook disposal on real DOM disconnect", () => {
it("tears down a useObservable subscription and stops requestUpdate when the element is removed", async () => {
const { observable, emit, subscriberCount } = createTrackedObservable();

const el = document.createElement("hook-probe-element") as HookProbeElement;
el.observable = observable;
document.body.appendChild(el);
await el.updateComplete;

// Mounted: the render subscribed exactly once.
expect(subscriberCount()).toBe(1);

// Track any requestUpdate that fires AFTER the element is removed.
let updatesAfterRemove = 0;
const originalRequestUpdate = el.requestUpdate.bind(el);
el.requestUpdate = (...args: Parameters<HookProbeElement["requestUpdate"]>) => {
updatesAfterRemove++;
originalRequestUpdate(...args);
};

el.remove();

// Disconnect ran the effect cleanup: the subscription is gone.
expect(subscriberCount()).toBe(0);

// A later emit reaches no leaked subscriber, so the detached element is
// never asked to update. Without the disposal fix, this would be >= 1.
emit(99);
expect(updatesAfterRemove).toBe(0);
});
});
94 changes: 94 additions & 0 deletions packages/data-lit/src/hooks/component/hooks-controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.
import { describe, it, expect, vi } from "vitest";
import type { ReactiveController } from "lit";
import type { Component } from "./component.js";
import { installHooksController } from "./hooks-controller.js";

/**
* Minimal stand-in for a Lit host: an EventTarget that captures the controller
* installed on it so the test can drive `hostConnected` / `hostDisconnected`
* directly (a real Lit element would need a DOM). `addController` mirrors Lit's
* contract of firing `hostConnected` synchronously when already connected.
*/
class FakeHost extends EventTarget implements Component {
isConnected = true;
hookIndex = 0;
hooks: any[] = [];
updatedListeners = new Set<() => void>();
requestUpdate = vi.fn();
controllers: ReactiveController[] = [];

addController(controller: ReactiveController) {
this.controllers.push(controller);
}
// Simulate Lit dispatching lifecycle to every registered controller.
connect() {
for (const c of this.controllers) c.hostConnected?.();
}
disconnect() {
for (const c of this.controllers) c.hostDisconnected?.();
}
}

describe("installHooksController", () => {
it("disposes every effect hook and resets the cursor on disconnect", () => {
const host = new FakeHost();
const disposeA = vi.fn();
const disposeB = vi.fn();
host.hooks = [
{ dispose: disposeA, dependencies: [] },
42, // a value slot (useState) — must be skipped, not crash
{ dispose: disposeB, dependencies: [] },
{ current: null }, // a ref slot — no dispose
];
host.hookIndex = 4;

installHooksController(host);
host.disconnect();

expect(disposeA).toHaveBeenCalledTimes(1);
expect(disposeB).toHaveBeenCalledTimes(1);
expect(host.hooks).toEqual([]);
expect(host.hookIndex).toBe(0);
});

it("dispatches connected / disconnected events across the lifecycle", () => {
const host = new FakeHost();
const onConnected = vi.fn();
const onDisconnected = vi.fn();
host.addEventListener("connected", onConnected);
host.addEventListener("disconnected", onDisconnected);

installHooksController(host);
host.connect();
expect(onConnected).toHaveBeenCalledTimes(1);

host.disconnect();
expect(onDisconnected).toHaveBeenCalledTimes(1);
});

it("forces a re-render only on RE-connect, not the first connect", () => {
const host = new FakeHost();
installHooksController(host);

host.connect(); // first mount
expect(host.requestUpdate).not.toHaveBeenCalled();

host.disconnect();
host.connect(); // reconnect
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
});

it("is idempotent — repeated installs register a single controller", () => {
const host = new FakeHost();
installHooksController(host);
installHooksController(host);
installHooksController(host);
expect(host.controllers).toHaveLength(1);
});

it("skips hosts that are not reactive controller hosts", () => {
const host = new EventTarget() as unknown as Component;
expect(() => installHooksController(host)).not.toThrow();
});
});
104 changes: 104 additions & 0 deletions packages/data-lit/src/hooks/component/hooks-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// © 2026 Adobe. MIT License. See /LICENSE for details.

import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { Component } from "./component.js";

/**
* Per-host flag marking that the hook-lifecycle controller is already
* installed, so repeated renders (and double-wrapped `render` methods) do
* not register duplicate controllers.
*/
const HOOKS_CONTROLLER = Symbol("data-lit.hooksController");

type ReactiveHost = Component & ReactiveControllerHost & { [HOOKS_CONTROLLER]?: boolean };

function isReactiveHost(host: Component): host is ReactiveHost {
return "addController" in host && typeof host.addController === "function";
}

/**
* Dispose every effect-style hook slot and reset the hook cursor so the next
* render re-initializes hooks from scratch. Only slots that expose a
* `dispose` function (effects, subscriptions) are torn down; value slots
* (state, memo, ref) are simply dropped.
*/
function disposeHooks(host: Component): void {
const { hooks } = host;
if (hooks) {
for (const hook of hooks) {
(hook as { dispose?: () => void } | undefined)?.dispose?.();
}
}
// Invariant: every live subscription (useEffect / useObservable / ...) exposes a
// `dispose` and is torn down in the loop above, so nothing should fire after this.
// A useState setter closure that still runs post-disconnect would write into the
// emptied array and requestUpdate a detached host. That signals an un-torn-down
// subscription (a bug at the subscription site), not something to guard here.
host.hooks = [];
host.hookIndex = 0;
}

/**
* Give a hook host a real disconnect edge.
*
* The hook model stores every effect's cleanup in `host.hooks[i].dispose`,
* but {@link useEffect} only invokes it when dependencies change — never when
* the element leaves the DOM. Left alone, every `useObservable` /
* `useObservableValues` / `useEffect` subscription leaks on unmount and keeps
* firing `requestUpdate` on a detached element. `useConnected` is likewise
* inert because nothing dispatches the `"connected"` / `"disconnected"`
* events it listens for.
*
* This installs a single Lit {@link ReactiveController} — the one hook-friendly
* path to a genuine `hostDisconnected` — that:
* - on connect, dispatches `"connected"`, and on every *re*-connect forces a
* re-render so the (previously torn-down) hooks re-initialize and
* re-subscribe;
* - on disconnect, dispatches `"disconnected"` then disposes and clears all
* hook slots (full unmount semantics).
*
* It is installed lazily from inside the wrapped `render` (see {@link withHooks}),
* which runs after `connectedCallback`, so `addController` fires `hostConnected`
* synchronously on the first mount. The install is idempotent per host.
*
* Non-Lit hosts (no `addController`) are skipped; they may dispatch the
* lifecycle events themselves.
*/
export function installHooksController(host: Component): void {
if (!isReactiveHost(host) || host[HOOKS_CONTROLLER]) {
return;
}
// Mark installed BEFORE addController below: on an already-connected host
// addController fires hostConnected synchronously, and setting the flag first
// guards against a re-entrant render re-installing a duplicate controller.
host[HOOKS_CONTROLLER] = true;

let connectedOnce = false;
const controller: ReactiveController = {
hostConnected() {
// The "connected" / "disconnected" events are for EXTERNAL listeners only.
// The internal useConnected does NOT depend on them: this fires before the
// render body attaches any listener, so useConnected is driven by its direct
// isConnected check plus useEffect cleanup instead.
host.dispatchEvent(new Event("connected"));
if (connectedOnce) {
// Reconnect: the previous disconnect disposed and cleared every
// hook slot, so re-render to re-run hooks and re-subscribe.
host.requestUpdate();
}
connectedOnce = true;
},
hostDisconnected() {
host.dispatchEvent(new Event("disconnected"));
disposeHooks(host);
},
};
try {
host.addController(controller);
} catch (error) {
// addController failed, so no controller is registered. Clear the flag so a
// later render can retry the install instead of being permanently skipped.
host[HOOKS_CONTROLLER] = undefined;
throw error;
}
}
1 change: 1 addition & 0 deletions packages/data-lit/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

export * from "./component/component.js";
export * from "./component/stack.js";
export * from "./component/hooks-controller.js";
export * from "./use-state.js";
export * from "./use-effect.js";
export * from "./use-connected.js";
Expand Down
5 changes: 5 additions & 0 deletions packages/data-lit/src/hooks/with-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type { Component } from "./component/component.js";
import { Component_stack } from "./component/stack.js";
import { installHooksController } from "./component/hooks-controller.js";

export function withHooks<This extends Component, Args extends any[], Return>(
target: object,
Expand All @@ -10,6 +11,10 @@ export function withHooks<This extends Component, Args extends any[], Return>(
): TypedPropertyDescriptor<(this: This, ...args: Args) => Return> {
const originalMethod = descriptor.value!;
descriptor.value = function (this: This, ...args: Args): Return {
// Give the host a real disconnect edge so effect/subscription cleanups
// actually run on unmount. Idempotent per host, so double-wrapped
// `render` methods install it only once.
installHooksController(this);
Component_stack.push(this);
try {
return originalMethod.apply(this, args);
Expand Down
Loading