Skip to content

Commit 07a9f12

Browse files
author
DevBot
committed
feat(core,element,protocol): implement ADR-0109 unified signal-DOM activation layer
- Add BindingDescriptor + applyBindingDescriptor() covering static/signal text, attr, class, html, render and event bindings. - Refactor jsx-render-dom.ts CSR path to emit descriptors through the unified binder. - Refactor open-element-hydration.ts DSD hydration to parse markers into descriptors and use the same binder. - Standardize hydration markers as @openelement/protocol/hydration-markers. - Split @openelement/core into ./static, ./hydrate, ./csr subpaths. - Remove @prop() decorator runtime; keep static props path. - Remove dead subscribeTo()/requestReactiveUpdate() and ReactiveHost. fix(ui,www): E2E search/theme regressions - Fix signal-render DocumentFragment anchor bug so result lists update. - Fix vite.config.ts theme-token regex to accept single/double quotes. chore(release,docs): ADR-0108 npm distribution updates - tools/publish-npm.ts: deno pack + publish:npm tasks. - tools/autoflow/release.ts uses deno pack and npm publish. - .github/workflows/autoflow-release.yml: setup-node + NPM_TOKEN. - Update create starter, README, and version-plan docs for npm-first. - Add @openelement/protocol aliases to tools/consumer-local.ts. Validation: deno task autoflow:ci 21/21 PASS; test 935/0; e2e 99/0.
1 parent 077f9b1 commit 07a9f12

37 files changed

Lines changed: 2685 additions & 521 deletions

.github/workflows/autoflow-release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,14 @@ jobs:
6464
if: ${{ inputs.plan == 'patch' }}
6565
env:
6666
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
67+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
6768
run: |
6869
deno task autoflow:patch-release --approved-plan ${{ inputs.approvedPlan || 'ADR-0105/v0.40.x-cleanup-train' }}
6970
- name: Approved release
7071
if: ${{ inputs.plan != 'patch' }}
7172
env:
7273
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
74+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
7375
run: |
7476
deno task autoflow:release --approved-plan ${{ inputs.approvedPlan }} --to ${{ inputs.version }}
7577

docs/adr/ADR-0108-deno-native-npm-distribution.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,10 @@ openElement v0.41.0 distribution is **Deno-native npm distribution**:
6060

6161
1. **Toolchain**: require Deno 2.8+, convert internal imports to `npm:`, add
6262
`deno task pack` / `deno task publish:npm`.
63-
2. **Boundaries**: move `FileIsrCache` to `@openelement/ssg`, make
64-
`router/page-loader` accept raw markdown, add `deno-api:check` gate.
63+
2. **Boundaries**: keep `MemoryIsrCache` in `@openelement/core/isr` as the
64+
reference ISR cache; `FileIsrCache` and `router/page-loader` were removed
65+
during the architecture audit cleanup because no production code consumed
66+
them. Add `deno-api:check` gate for runtime-free packages.
6567
3. **Adapter-vite**: default `ssg-package-resolver` to npm mode; JSR source
6668
fetch remains opt-in.
6769
4. **Starter**: `@openelement/create` emits `npm:` imports and resolves versions
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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.

docs/adr/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ architectural decision, its context, and consequences.
9797
| 0105 | v0.40.x Cleanup Train Exception | Accepted |
9898
| 0106 | Audit-Driven Quality Cleanup for v0.40.6 | Accepted |
9999
| 0107 | npm-Only Distribution | Accepted |
100+
| 0108 | Deno-native npm Distribution via `deno pack` | Accepted |
101+
| 0109 | Unified Signal-DOM Activation Layer | Proposed |
100102

101103
## Superseded / Historical
102104

docs/current/VERSION_PLAN.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,15 @@ so this plan pivots to Deno's own `deno pack` tooling.
3434

3535
### Runtime-agnostic boundaries
3636

37-
- Move `FileIsrCache` from `@openelement/core/isr` to
37+
- ~~Move `FileIsrCache` from `@openelement/core/isr` to
3838
`@openelement/ssg/file-isr-cache`; keep the interface and `MemoryIsrCache` in
39-
`core`.
40-
- Change `router/src/page-loader.ts` `loadPage()` to accept raw markdown text
41-
instead of reading files with `Deno.readTextFile`.
39+
`core`.~~ Superseded by architecture audit cleanup: `FileIsrCache` was removed
40+
because no production code consumed it. `MemoryIsrCache` remains the reference
41+
implementation in `@openelement/core/isr`.
42+
- ~~Change `router/src/page-loader.ts` `loadPage()` to accept raw markdown text
43+
instead of reading files with `Deno.readTextFile`.~~ Superseded by cleanup:
44+
`router/src/page-loader.ts` was removed during architecture audit. Raw markdown
45+
rendering remains available in `@openelement/content`.
4246
- Add `tools/check-deno-api-free.ts` and a `deno task deno-api:check` gate that
4347
fails if `core/element/ui/protocol/signal/router/app` source files use
4448
`Deno.*`.

docs/release/v0.41.0-alpha1-plan.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,49 @@ Run on `dev` after all workstreams completed:
163163
- `deno task repo:hygiene` — passed
164164
- `deno task autoflow:ci` — 21/21 gates PASS
165165

166+
## Post-Alpha Roadmap Tasks (from ADR-0108 and ADR-0109)
167+
168+
The following tasks are accepted follow-up work emerging from ADR-0108
169+
(Deno-native npm distribution) and ADR-0109 (Unified Signal-DOM Activation Layer).
170+
They are tracked here so the alpha plan remains the single source of truth for
171+
v0.41.0 scope.
172+
173+
### ADR-0108 — Deno-native npm Distribution
174+
175+
- [x] Confirm Deno 2.8+ and update minimum-version docs/CI.
176+
- [x] Convert internal `@openelement/*` imports from `jsr:` to `npm:` across workspace. (note: source now uses bare workspace specifiers; no internal `jsr:@openelement/*` imports remain)
177+
- [x] Implement `deno task pack` (topological 11-package npm tarballs via `deno pack`).
178+
- [x] Implement `deno task pack:dry-run` for CI validation.
179+
- [x] Implement `deno task publish:npm` with provenance.
180+
- [x] Move `FileIsrCache` from `@openelement/core/isr` to `@openelement/ssg/file-isr-cache` — superseded by cleanup: the persistent ISR cache was removed after architecture audit found no production callers. `MemoryIsrCache` remains in `@openelement/core/isr` as the reference implementation.
181+
- [x] Refactor `router/page-loader.ts` to accept raw markdown instead of `Deno.readTextFile` — superseded by cleanup: `page-loader.ts` was removed during architecture audit. Content rendering remains in `@openelement/content` with raw markdown handled by existing MDX/markdown pipeline.
182+
- [x] Add and enforce `deno task deno-api:check` gate for runtime-free packages.
183+
- [x] Default `adapter-vite` ssg-package-resolver to npm mode; keep JSR opt-in.
184+
- [x] Update `@openelement/create` starter to emit `npm:` imports.
185+
- [x] Update `tools/autoflow/release.ts` to use `deno pack` + `npm publish`.
186+
- [x] Update GitHub Actions `autoflow-release.yml` with `actions/setup-node` and `NPM_TOKEN`.
187+
- [x] Add npm consumer smoke for Node ESM, Deno `npm:`, jsDelivr, and Nitro Node/Workers.
188+
- [x] Update README/starter/migration docs for npm-first distribution.
189+
- [x] Keep JSR monitoring workflows as historical observation only.
190+
191+
### ADR-0109 — Unified Signal-DOM Activation Layer (P0 + P1)
192+
193+
- [x] Design `BindingDescriptor` type and `Lifecycle` interface in `packages/core`.
194+
- [x] Implement `applyBindingDescriptor()` covering text/attr/class/render/event bindings.
195+
- [x] Refactor `jsx-render-dom.ts:applyProps()` to emit descriptors and call unified binder.
196+
- [x] Refactor `open-element-hydration.ts:hydrateSignals()` to parse markers into descriptors and call unified binder.
197+
- [x] Refactor event binding to emit `event` descriptors through the unified binder.
198+
- [x] Make `signalRegistry` required for CSR path as well as DSD hydration.
199+
- [x] Add/update unit tests covering unified binding for CSR, hydration, dispose, and updates.
200+
- [x] Run E2E suite and verify no regressions.
201+
- [x] Remove `@prop()` decorator runtime; keep only `static props`.
202+
- [x] Split `@openelement/core` into `static`, `hydrate`, and `csr` subpath exports.
203+
- [x] Standardize marker format and document it as a protocol contract.
204+
- [x] Reduce `requestReactiveUpdate()` usage where signal-driven binding descriptor suffices. — removed dead `subscribeTo()`/`requestReactiveUpdate()` methods; descriptor paths already cover signal-driven updates.
205+
166206
## Acceptance
167207

168-
- [x] `deno task test` passes. (894 passed, 0 failed)
208+
- [x] `deno task test` passes. (935 passed, 0 failed)
169209
- [x] `deno task lint` and `deno task fmt:check` pass.
170210
- [x] `deno task typecheck` passes.
171211
- [x] `deno task build` passes.
@@ -174,6 +214,27 @@ Run on `dev` after all workstreams completed:
174214
dependencies.
175215
- [x] `packages/protocol` is restored as minimal type-only protocol layer.
176216

217+
## Reconciliation verification
218+
219+
After ADR-0108/ADR-0109 implementation, E2E fixes, and `requestReactiveUpdate()` cleanup:
220+
221+
- `deno task fmt` — passed
222+
- `deno task fmt:check` — passed
223+
- `deno task lint` — passed
224+
- `deno task typecheck` — passed
225+
- `deno task test` — passed (935 passed, 0 failed)
226+
- `deno task graph:check` — passed
227+
- `deno task arch:check` — passed
228+
- `deno task docs:check-public` — passed
229+
- `deno task docs:check-current` — passed
230+
- `deno task docs:check-strategy` — passed
231+
- `deno task repo:hygiene` — passed
232+
- `deno task workflow:check` — passed
233+
- `deno task build` — passed
234+
- `deno task consumer:packaged` — passed
235+
- `deno task test:e2e` — passed (Chromium, 99 passed, 0 failed)
236+
- `deno task autoflow:ci` — passed (21/21 gates)
237+
177238
## Post-Plan Fixes & Follow-up
178239

179240
### v0.41.0-alpha1 - Supply Chain (sanitize-html, flexsearch)

docs/release/v0.41.0.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,15 @@ features or package graph changes.
2323

2424
### Runtime-agnostic Boundaries
2525

26-
- Move `FileIsrCache` from `@openelement/core/isr` to
26+
- ~~Move `FileIsrCache` from `@openelement/core/isr` to
2727
`@openelement/ssg/file-isr-cache`; keep `MemoryIsrCache` and the cache
28-
interface in `core`.
29-
- Change `router/src/page-loader.ts` `loadPage()` to accept raw markdown text
30-
instead of reading files with `Deno.readTextFile`.
28+
interface in `core`.~~ Superseded by architecture audit cleanup: `FileIsrCache`
29+
was removed after audit confirmed no production callers. `MemoryIsrCache`
30+
remains the reference implementation.
31+
- ~~Change `router/src/page-loader.ts` `loadPage()` to accept raw markdown text
32+
instead of reading files with `Deno.readTextFile`.~~ Superseded by cleanup:
33+
`router/src/page-loader.ts` was removed during architecture audit. Raw markdown
34+
rendering remains available in `@openelement/content`.
3135
- Add `tools/check-deno-api-free.ts` and a `deno task deno-api:check` gate that
3236
fails if `core/element/ui/protocol/signal/router/app` source files use
3337
`Deno.*`.

0 commit comments

Comments
 (0)