From e74a0ee622172198e979a5d98551b57ae9f57c4e Mon Sep 17 00:00:00 2001 From: Heliton Nordt <1625399+hnordt@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:35:53 -0300 Subject: [PATCH 1/2] Document the first Web Components integration draft --- ...-web-components-integration-with-preact.md | 704 ++++++++++++++++++ docs/design/README.md | 1 + 2 files changed, 705 insertions(+) create mode 100644 docs/design/0010-web-components-integration-with-preact.md diff --git a/docs/design/0010-web-components-integration-with-preact.md b/docs/design/0010-web-components-integration-with-preact.md new file mode 100644 index 0000000..9659458 --- /dev/null +++ b/docs/design/0010-web-components-integration-with-preact.md @@ -0,0 +1,704 @@ +# 0010: Web Components integration with Preact + +| Field | Value | +| ------------ | ------------------ | +| Status | Draft | +| Scope | Kernel, Experience | +| Created | 2026-08-02 | +| Last updated | 2026-08-02 | + +## Summary + +Hyperkernel should evaluate Custom Elements as a framework-neutral browser +boundary for application interface components. The first draft exposes an +abstract `HKElement` base class from a proposed `@hyperkernel/ui` package, +uses Preact to render a `VNode` into the element's light DOM, and leaves +registration with the client entrypoint. + +The accompanying delivery experiment uses Deno to produce a browser-targeted +ES module in memory, emits Custom Element hosts from server-rendered HTML, and +reloads the complete page after a successful development rebuild. It +deliberately does not attempt hot module replacement because registrations in +the browser's Custom Element registry cannot be replaced safely. + +This record preserves a first design for evaluation. It does not introduce the +`@hyperkernel/ui` package, adopt Deno or Preact in the current application, +replace SvelteKit, define a stable component contract, or claim that the code +shown here has been implemented or verified in this repository. + +## Classification + +This record is an Experience change because it concerns component authoring, +browser delivery, and development tooling. It also has Kernel scope because +`HKElement`, if published through `@hyperkernel/ui`, would become a public SDK +contract. The experiment does not enter the authoritative write transaction or +change command, event, projection, authorization, persistence, or replay +behavior. + +## Relationship to the current frontend decision + +[0003: Web-platform-first frontend](0003-web-platform-first-frontend.md) keeps +Svelte and SvelteKit as the chosen application model and currently rejects +Custom Elements without Svelte as the general frontend foundation. This draft +does not override that decision. + +The narrower question here is whether Custom Elements can provide a useful +application-facing interoperability boundary while a small renderer supplies +declarative DOM updates inside each element. The experiment must demonstrate a +material benefit over ordinary Svelte components before it can change the +current frontend direction. + +## Problem + +Hyperkernel wants independently developed applications and interface +components to compose through contracts that remain understandable outside one +frontend framework. Custom Elements provide browser-owned registration, +lifecycle, attribute, property, event, and DOM composition primitives, but they +do not provide a complete declarative rendering or reactive state model. + +A local rendering convention can fill that narrow gap, but it creates several +contracts that are easy to leave implicit: + +- whether Preact is an implementation detail or part of the author-facing API; +- who owns element registration and lifecycle callbacks; +- whether rendering uses light DOM or Shadow DOM; +- how attributes, properties, events, slots, and styles cross the boundary; +- whether server output contains component content or only an upgradeable host; +- what happens to component state during development reloads; +- how browser and server module graphs remain isolated; +- how bundle, watch, and reload failures remain observable and recoverable; +- how a proposed UI SDK avoids bypassing Hyperkernel's command boundary. + +The first experiment needs enough explicit behavior to be tested without +prematurely turning the draft into a general component framework. + +## Invariants + +1. A module that evaluates `HTMLElement` or `customElements` executes only in a + browser module graph. Server-side rendering may emit a Custom Element tag + without importing or evaluating its browser class. +2. Each Custom Element name is registered at most once in one document. A + development update that changes the class causes a full page reload instead + of attempting to replace the existing registration. +3. `HKElement.update()` renders only into its own host. One element must not + mutate another element's DOM or take ownership of the document root. +4. The initial render target is the element's light DOM. Shadow DOM, + encapsulation, slots, and scoped style behavior are not implied. +5. The server swaps the active in-memory bundle only after a complete + successful build. A failed rebuild must not publish a partial bundle or + announce a successful reload. +6. Server-emitted `` markup is an upgradeable host, not evidence that + the component itself was server-rendered or hydrated. +7. A full page reload discards transient JavaScript and DOM state. Durable + user-visible state must be restored through supported Hyperkernel state + boundaries rather than depending on hot-module preservation. +8. A Custom Element may submit commands and read supported queries. It never + receives direct authority to mutate authoritative database, event, or + projection state. +9. Native element, focus, keyboard, form, and accessibility behavior remains + intact unless the component defines and tests an intentional alternative. +10. Nothing in this Draft is a supported compatibility contract. If element + names, attributes, properties, events, or `HKElement` become public, their + evolution must be reviewed as public SDK evolution. + +## Proposed direction + +### Use the Custom Element as the browser boundary + +The browser-facing identity of a component is its registered tag name, such as +`hk-foo`. The client entrypoint imports the implementation and owns the single +`customElements.define()` call. HTML produced by a server, another framework, +or static markup can use that tag without importing Preact. + +Registration remains explicit rather than being a side effect of importing the +class. This keeps the global registry mutation visible at the composition root +and lets tests construct the class without necessarily choosing a production +tag name. + +Tag names are globally scarce within a document and cannot be undefined. The +`hk-foo` name is illustrative only; a naming and compatibility policy must be +chosen before publishing real application elements. + +### Use a minimal Preact-backed base class + +The proposed base class has one abstract render contract and one explicit +update operation: + +```ts +import { render as preactRender, type VNode } from "preact"; + +export default abstract class HKElement extends HTMLElement { + protected abstract render(): VNode; + + update(): void { + preactRender(this.render(), this); + } +} +``` + +The imported renderer is aliased so that it cannot be confused with the +subclass method. `render()` is protected because it describes how the element +builds its own view. `update()` remains public in the first draft, although the +experiment must determine whether external callers need that capability or +whether it should also be protected. + +Because subclasses return Preact's `VNode`, Preact is part of the proposed +authoring API rather than a completely private implementation detail. Consumers +using the resulting `hk-*` element do not need Preact, but authors extending +`HKElement` do. A future renderer replacement would therefore require either a +compatibility layer or a new base-class contract. + +### Keep lifecycle ownership explicit + +The first component asks for its initial render from `connectedCallback()`: + +```tsx +import HKElement from "@hyperkernel/ui"; + +export default class HKFoo extends HKElement { + connectedCallback(): void { + this.update(); + } + + protected render() { + return

Hello! This is a custom element.

; + } +} +``` + +The base class does not yet prescribe `connectedCallback()`, +`disconnectedCallback()`, `adoptedCallback()`, or attribute observation. This +keeps the first abstraction small, but it leaves cleanup, reconnection, and +attribute-driven rendering undefined. The experiment must resolve those +behaviors before the class is published. + +### Render into light DOM first + +Passing `this` to Preact renders children directly inside the Custom Element. +This keeps the result inspectable, lets document styles and theme tokens reach +the content, and avoids adopting an encapsulation and slotting policy before a +component requires one. + +The cost is that component internals are not style-isolated and may be selected +or mutated by surrounding code. Shadow DOM is not rejected permanently, but it +must be introduced only with an explicit accessibility, theming, focus, +server-rendering, and composition contract. + +### Keep browser and server module graphs separate + +The Deno server bundles `client/main.ts` as a browser-targeted ES module. It +does not import `HKFoo` or `HKElement` into the server module graph. The server +may use Preact's string renderer to emit `` as inert HTML, and the +browser bundle later registers and upgrades that host. + +The initial flow is: + +```mermaid +flowchart LR + W["Deno watcher"] --> B["Browser-targeted bundle"] + B --> F["In-memory JavaScript file"] + F --> H["HTML module script"] + H --> R["Register hk-foo"] + R --> U["Upgrade host element"] + U --> P["Preact renders into light DOM"] + B --> S["Development SSE message"] + S --> L["Full page reload"] +``` + +The server file contains JSX and is therefore named `server/main.tsx`. The +current `Deno.bundle()` runtime API is experimental, requires the +`--unstable-bundle` flag, and must be revalidated against the chosen Deno +version before this delivery path is adopted. + +### Prefer full reload to component hot replacement + +The development client opens an `EventSource` connection to `/dev`. After the +watcher observes a source change and a replacement bundle succeeds, the server +sends a message and the browser calls `location.reload()`. + +This intentionally resets the document and its Custom Element registry. It +avoids cache-busted imports that would execute another +`customElements.define("hk-foo", ...)` in the same document and fail. It also +keeps development behavior closer to a clean application startup. + +The reload channel is development-only. A production build must not create the +watcher, expose `/dev`, or open the `EventSource` connection. + +### Treat outer HTML rendering separately from component SSR + +`renderToStaticMarkup()` renders the document shell and the `` host. It +does not call `HKFoo.render()` on the server and does not serialize its `

` +content. The first visible component content is produced after the browser +loads the module and upgrades the element. + +If the experiment later requires component HTML before JavaScript executes, it +must define a separate server-rendering and hydration contract. That contract +must prevent duplicate DOM, mismatched markup, and destructive upgrades and +must state whether Declarative Shadow DOM is involved. + +## Reference sketch + +This sketch normalizes the first draft by using the same `/dev` path on the +client and server, naming the JSX server file `.tsx`, targeting the browser +explicitly, and checking the bundler's success result. It remains illustrative +and unverified in this repository. + +```text +ui/HKElement.ts +ui/elements.d.ts +client/components/HKFoo.tsx +client/main.ts +server/main.tsx +deno.json +``` + +### `ui/elements.d.ts` + +```ts +export {}; + +declare global { + namespace preact.JSX { + interface IntrinsicElements { + "hk-foo": Record; + } + } +} +``` + +The declaration is shared because both the browser entrypoint and the +server-rendered shell use `` in separate module graphs. + +### `client/main.ts` + +```ts +import type {} from "../ui/elements.d.ts"; +import HKFoo from "./components/HKFoo.tsx"; + +customElements.define("hk-foo", HKFoo); + +// Development entrypoints only. +new EventSource("/dev").addEventListener("message", () => { + location.reload(); +}); +``` + +The JSX declaration allows `` but deliberately defines no attributes +yet. + +### `server/main.tsx` + +```tsx +import type {} from "../ui/elements.d.ts"; +import { renderToStaticMarkup } from "preact-render-to-string"; + +const client = { + files: [] as Array, + + async bundle(): Promise { + const result = await Deno.bundle({ + entrypoints: ["./client/main.ts"], + platform: "browser", + format: "esm", + write: false, + }); + + result.warnings.forEach((warning) => console.info(warning.text)); + + if (!result.success) { + throw new Error(result.errors[0]?.text ?? "Client bundle failed"); + } + + if (!result.outputFiles) { + throw new Error("Client bundle generated no output files"); + } + + const replacement = result.outputFiles.map( + (output) => + new File([output.text()], output.hash, { + type: "text/javascript;charset=utf-8", + }), + ); + + this.files = replacement; + }, +}; + +await client.bundle(); + +const server = Deno.serve((request) => { + const url = new URL(request.url); + const file = client.files.find( + (candidate) => candidate.name === url.pathname.slice(1), + ); + + if (file) { + return new Response(file, { + headers: { + "Cache-Control": "no-cache", + "Content-Type": file.type, + }, + }); + } + + if (url.pathname === "/dev") { + const encoder = new TextEncoder(); + const watcher = Deno.watchFs("./client"); + + const body = new ReadableStream({ + start(controller) { + void (async () => { + try { + for await (const event of watcher) { + await client.bundle(); + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + type: "SOURCE_CHANGED", + payload: event, + })}\n\n`, + ), + ); + } + } catch (error) { + controller.error(error); + } + })(); + }, + + cancel() { + watcher.close(); + }, + }); + + return new Response(body, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + }, + }); + } + + const html = renderToStaticMarkup( + + + + {client.files.map((file) => ( +