|
| 1 | +# ADR-0109: Unified Signal-DOM Activation Layer |
| 2 | + |
| 3 | +- Status: ACCEPTED (P0 + P1 implemented; P2 remains future work) |
| 4 | +- Date: 2026-06-22 |
| 5 | + |
| 6 | +## Context |
| 7 | + |
| 8 | +OpenElement currently has two independent signal→DOM binding paths: |
| 9 | + |
| 10 | +- **Path A (CSR creation)**: `packages/core/src/jsx-render-dom.ts:applyProps()` binds signals to DOM nodes while they are being created from a VNode tree. |
| 11 | +- **Path D (DSD hydration)**: `packages/element/src/open-element-hydration.ts:hydrateSignals()` binds signals to DOM nodes that already exist inside a Declarative Shadow DOM template, discovering them via `data-signal*` markers and resolving signal identity through `signalRegistry`. |
| 12 | + |
| 13 | +Both paths implement the same binding semantics—text content, attributes, CSS classes, and VNode child rendering—but with different input sources (VNode props vs. DOM attributes) and different cleanup wiring. This duplication was noted in the v0.41.0-alpha1 architecture review and flagged as technical debt. |
| 14 | + |
| 15 | +We have also considered more radical cures: |
| 16 | + |
| 17 | +- A framework-specific JSX→imperative-DOM compiler. This would eliminate the split by making CSR and hydration share the same generated binding code, but it turns OpenElement into a Stencil-like toolchain and violates our “no framework compiler” boundary. |
| 18 | +- Tagged template literals (Lit-style `html`\`...\``). This would give us a TemplateResult/Part abstraction that naturally unifies SSR and hydration, but it abandons JSX and makes OpenElement a direct Lit competitor. |
| 19 | + |
| 20 | +Both were rejected. We need a solution that keeps JSX, keeps no framework compiler, and still removes the duplicated binding logic. |
| 21 | + |
| 22 | +## Decision |
| 23 | + |
| 24 | +Introduce a single **Activation Layer** that owns all signal→DOM and event→DOM bindings. The layer consumes abstract **Binding Descriptors** and is agnostic to how those descriptors were produced. |
| 25 | + |
| 26 | +### Binding Descriptor abstraction |
| 27 | + |
| 28 | +A descriptor is a plain object describing one binding: |
| 29 | + |
| 30 | +```ts |
| 31 | +type BindingDescriptor = |
| 32 | + | { kind: 'text'; target: Node; signal: Signal<unknown> } |
| 33 | + | { kind: 'attr'; el: Element; attr: string; signal: Signal<unknown> } |
| 34 | + | { kind: 'class'; el: Element; class: string; signal: Signal<unknown> } |
| 35 | + | { kind: 'render'; el: Element; signal: Signal<unknown> } |
| 36 | + | { kind: 'event'; el: Element; type: string; handler: EventListener; signal?: AbortSignal }; |
| 37 | +``` |
| 38 | + |
| 39 | +One function applies the descriptor and returns a dispose function: |
| 40 | + |
| 41 | +```ts |
| 42 | +applyBindingDescriptor(desc: BindingDescriptor, lifecycle: Lifecycle): () => void |
| 43 | +``` |
| 44 | + |
| 45 | +### Two discovery paths, one binding implementation |
| 46 | + |
| 47 | +| Path | Discovery | Binding | |
| 48 | +| ------------- | ---------------------------------------------------------------------------- | -------------------------- | |
| 49 | +| CSR creation | `applyProps()` walks VNode props and emits descriptors | `applyBindingDescriptor()` | |
| 50 | +| DSD hydration | `hydrateSignals()` parses `data-signal*` markers into descriptors | `applyBindingDescriptor()` | |
| 51 | +| Events | `collectEventBindings()` / `hydrateEventMarkers()` emits `event` descriptors | `applyBindingDescriptor()` | |
| 52 | + |
| 53 | +The split is reduced from “two binding implementations” to “two descriptor discovery mechanisms.” |
| 54 | + |
| 55 | +### Layered package exports |
| 56 | + |
| 57 | +Split `@openelement/core` into runtime-scoped subpaths so that static DSD components do not pay for DOM binding code: |
| 58 | + |
| 59 | +- `@openelement/core/static` — SSR/SSG-only code paths; no DOM binding, no signal effects. |
| 60 | +- `@openelement/core/hydrate` — DSD interactive components; marker hydration + events. |
| 61 | +- `@openelement/core/csr` — CSR fallback and pure islands; full `renderToDom`. |
| 62 | + |
| 63 | +### What we are NOT doing |
| 64 | + |
| 65 | +- No framework-specific JSX compiler. |
| 66 | +- No tagged template literal syntax. |
| 67 | +- No virtual DOM diff/reconciliation. |
| 68 | +- No synthetic event system. |
| 69 | + |
| 70 | +## Consequences |
| 71 | + |
| 72 | +### Positive |
| 73 | + |
| 74 | +- Binding logic lives in one place; fixes and new binding kinds only require one change. |
| 75 | +- CSR and hydration share the same effect lifecycle and disposal semantics. |
| 76 | +- Static DSD components can avoid loading DOM binding code entirely. |
| 77 | +- The architecture stays aligned with ADR-0057 (no framework compiler), ADR-0065 (VNode as description), and ADR-0067 (DSD-first signal-native hydration). |
| 78 | +- Future migration to TC39 Signals or DOM Parts only requires replacing `applyBindingDescriptor`, not two parallel paths. |
| 79 | + |
| 80 | +### Negative |
| 81 | + |
| 82 | +- We still have two descriptor discovery mechanisms, so the split is not fully gone—just moved and standardized. |
| 83 | +- Without a compiler, we cannot eliminate the runtime cost of walking VNodes or querying markers. |
| 84 | +- `signalRegistry` must remain a required concept for both CSR and hydration. |
| 85 | + |
| 86 | +### Neutral |
| 87 | + |
| 88 | +- The user-facing API does not change. |
| 89 | +- JSX compilation remains the standard TypeScript automatic JSX transform. |
| 90 | + |
| 91 | +## VNode naming |
| 92 | + |
| 93 | +We keep the public name `VNode`. It is entrenched in the protocol, core, element packages, tests, and existing ADRs. Renaming it would be a broad breaking change with limited practical benefit. |
| 94 | + |
| 95 | +Instead, we document its actual meaning: |
| 96 | + |
| 97 | +> `VNode` is a **JSX description node**: a short-lived, plain-object description of a DOM subtree. It is not a virtual DOM node and is not retained as a runtime tree. |
| 98 | +
|
| 99 | +If a clearer public name becomes necessary in the future, we can introduce it as an alias and deprecate `VNode` gradually. |
| 100 | + |
| 101 | +## Long-term targets |
| 102 | + |
| 103 | +We do **not** commit to a framework-specific compiler or to tagged templates. |
| 104 | + |
| 105 | +- **Compiler**: Treated only as a fallback if runtime VNode costs are proven unacceptable and partial pre-compilation fails. |
| 106 | +- **Tagged templates**: Explicitly out of scope; incompatible with our JSX-first product identity. |
| 107 | +- **Real long-term targets**: Web platform standards that remove the need for framework-owned binding code: |
| 108 | + - TC39 Signals |
| 109 | + - DOM Templating API / DOM Parts |
| 110 | + - Declarative Custom Elements |
| 111 | + |
| 112 | +When these standards mature, OpenElement can migrate `applyBindingDescriptor` to use native primitives without changing the user-facing JSX API. |
| 113 | + |
| 114 | +## Implementation roadmap |
| 115 | + |
| 116 | +### P0 (this cycle) |
| 117 | + |
| 118 | +1. Create `BindingDescriptor` type and `applyBindingDescriptor()` in `packages/core`. |
| 119 | +2. Refactor `jsx-render-dom.ts:applyProps()` to emit descriptors and call the unified binder. |
| 120 | +3. Refactor `open-element-hydration.ts:hydrateSignals()` to parse markers into descriptors and call the unified binder. |
| 121 | +4. Refactor event binding (`event-hydration.ts`) to emit `event` descriptors through the same binder. |
| 122 | + |
| 123 | +### P1 (next cycle) |
| 124 | + |
| 125 | +5. Remove `@prop()` decorator runtime; keep only `static props`. |
| 126 | +6. Split `@openelement/core` into `static`, `hydrate`, and `csr` subpaths. |
| 127 | +7. Standardize marker format and document it as a protocol contract. |
| 128 | + |
| 129 | +### P2 (future) |
| 130 | + |
| 131 | +8. Add TC39 Signal engine adapter when the standard stabilizes. |
| 132 | +9. Add DOM Templating output adapter when the API is available. |
| 133 | +10. Evaluate Declarative Custom Element generation for supported browsers. |
0 commit comments