- Status: Proposed
- Date: 2026-05-17
- Target: v0.19.0 (Phase 3)
- Supersedes: ADR-0029 (Happy DOM for DOM Simulation)
The Hub component detail pages need rendered previews of Web Components.
The current implementation uses happy-dom to simulate a browser environment
at build time (hub:scan), then serializes the shadow DOM to static HTML.
This approach has accumulated significant technical debt:
-
Type incompatibility — happy-dom's
Window,HTMLElement,Nodetypes don't overlap with browser-native types. Every interaction requiresas unknown asintermediate casts (5+ locations insnapshot-renderer.ts). -
Slot extraction fragility —
el.childrenonly returns Element nodes; Text nodes (e.g.,innerHTML = "plain text") are silently dropped. Fixed once, but similar edge cases are inevitable with any DOM simulation. -
Constructable stylesheets not serialized — Lit uses
adoptedStyleSheetswhich don't appear ininnerHTML. We had to add explicit conversion logic (adoptedStyleSheets→<style>tags), and this pattern repeats for every browser API that behaves differently in simulation vs reality. -
Global state pollution — happy-dom requires replacing ALL global constructors (HTMLElement, customElements, ShadowRoot, Node, Element, Event, CSSStyleSheet, ResizeObserver, localStorage, getComputedStyle, navigator, matchMedia, DOMParser, MutationObserver, DocumentFragment...). Missing one causes silent failures.
-
Subprocess isolation required — Lit's module caching with
@lit-labs/ssr-dom-shimconflicts with happy-dom's globals, forcing each component render into a fresh Deno subprocess. This adds ~1-2s per component (53 components × 1.5s = ~80s forhub:scan). -
:host selector replacement hack —
:hostCSS selectors only work inside shadow DOM. We replace them with the tag name via regex, which breaks for:host(.class)and:host-context()selectors. -
Media Chrome's isShimmed detection — Components that check
globalThis.global === globalThis(Media Chrome'sserver-safe-globals.js) require additional patches to bypass server-mode no-ops.
The fundamental issue: happy-dom simulates a browser, but it is NOT a browser. Every behavioral difference is a potential bug we must discover, diagnose, and patch.
For Hub component previews, we need the rendered HTML output of each component — the same HTML a real browser would produce. We do NOT need:
- Runtime interactivity in the snapshot
- JavaScript execution in the preview page
- Event handling or user interaction
This means: a real browser can render the component, we capture the DOM state after rendering, and serialize it to static HTML. No simulation needed.
Replace happy-dom snapshot rendering with Playwright-based real browser rendering.
Use the existing Playwright + Chromium setup (already in CI for e2e tests) to render each component in a real browser, capture the serialized shadow + light DOM, and save as static HTML snapshots.
hub:scan
│
├─ For each package:
│ │
│ ├─ Generate fixture HTML page (one page per component)
│ │ - <script type="module"> imports component
│ │ - Instantiates component with DEMO_ATTRS + DEMO_SLOTS
│ │ - Appends to document.body
│ │
│ ├─ Start temp HTTP server (Deno.serve)
│ │
│ ├─ Playwright: navigate to each fixture page
│ │ - Wait for component to render (waitForSelector + setTimeout)
│ │ - Evaluate: capture shadowRoot.innerHTML + light DOM
│ │ - Evaluate: serialize adoptedStyleSheets to <style> tags
│ │ - Extract computed slot assignments
│ │
│ ├─ Post-process captured HTML:
│ │ - Sanitize (strip <script>, on* handlers, javascript: URLs)
│ │ - Replace <slot> with fallback content from light DOM
│ │ - Wrap in snapshot-preview container
│ │ - Add theme CSS variables
│ │
│ └─ Stop temp server
│
└─ Write hub-index + hub-data TypeScript modules
-
One fixture page per component (not per package) — simpler error isolation, no cross-component interference, parallel rendering possible.
-
Inline fixture HTML — no file I/O for fixtures; generate HTML strings and serve from memory via a lightweight HTTP server.
-
Single Playwright browser instance — launch once, create a new page per component. This is dramatically faster than launching a browser per component (happy-dom's subprocess model was ~1.5s/component; Playwright page navigation is ~50-100ms/page).
-
Temp HTTP server — components need real URL resolution for module imports. A lightweight
Deno.serveon a random port provides this without file I/O. -
Shadow DOM serialization via
page.evaluate()— run JavaScript in the real browser context to extractshadowRoot.innerHTML,adoptedStyleSheets, and slot assignments. This is the ONLY reliable way to get accurate output. -
SSR-capable @lessjs/ui components — can continue using
renderSnapshotLit()(ADR-0029's@lit-labs/ssr-dom-shimapproach) since it works perfectly for first-party Lit components. The Playwright path is used for client-only npm packages (Shoelace, Media Chrome) where happy-dom was previously needed.
| File | Lines | Reason |
|---|---|---|
packages/hub/src/cli/render-happy.ts |
299 | Entire subprocess renderer replaced |
packages/hub/src/snapshot-renderer.ts (happy-dom section) |
~300 | renderSnapshotWithHappyDom() removed |
render-happy subprocess invocation in scanner.ts |
~30 | No more Deno.Command(Deno.execPath()) |
happy-dom npm dependency |
— | No longer needed for snapshots |
| File | Purpose |
|---|---|
packages/hub/src/snapshot-playwright.ts |
Playwright-based renderer: server, fixture pages, capture, post-process |
packages/hub/__tests__/snapshot-playwright.test.ts |
Integration test: render sl-button, verify HTML output |
| Metric | happy-dom (current) | Playwright (proposed) |
|---|---|---|
| Per-component time | ~1.5s (subprocess spawn) | ~0.1s (page navigation) |
| Total 53 components | ~80s | ~6-8s |
| CI requirement | Deno only | Chromium (already installed) |
| Global state pollution | Yes (patch 15+ globals) | No (isolated browser tabs) |
| Type errors | 5+ (happy-dom ≠ browser types) | 0 (Playwright types are first-class) |
| Rendering accuracy | ~90% (happy-dom gaps) | ~99% (real Chromium) |
| New browser API support | Requires manual patching | Automatic (it IS a browser) |
- Eliminates all happy-dom class of bugs: type incompatibilities, slot extraction, adoptedStyleSheets, global pollution, :host replacement, Media Chrome isShimmed.
- 10-15x faster: single browser instance + page navigation vs subprocess spawn.
- Zero type hacks: no
as unknown ascasts needed; Playwright API is TypeScript-first with full type definitions. - Accurate rendering: real Chromium renders exactly as users will see it.
- Future-proof: new browser APIs (View Transitions, CSS Container Queries, Popover API) work automatically — no simulation patches needed.
- Reuses existing CI infrastructure: Playwright + Chromium already installed for e2e tests.
- Simpler mental model: "render in browser, capture output" vs "simulate browser, patch globals, serialize manually".
- Requires Chromium at build time:
hub:scanneeds Playwright + Chromium installed. This is already true for CI and local dev (e2e tests use it). For environments without Chromium, a--skip-snapshotsflag can be added. - Playwright dependency for hub package:
@lessjs/hubgains a dev-time dependency onplaywright. This does NOT affect runtime or published packages. - HTTP server for module resolution: components import from npm specifiers which need URL resolution. A temporary HTTP server (Deno.serve) is required.
renderSnapshotLit()for @lessjs/ui components stays unchanged — it works perfectly and is faster than Playwright for SSR-capable Lit components.- The
DEMO_ATTRSandDEMO_SLOTSconfiguration maps stay the same — they describe what the component should look like, not how to render it. - Snapshot output format (HTML string in
HubTagRecord.ssrSnapshot) stays the same — only the generation mechanism changes.
Every few weeks, a new edge case appears (Text nodes, adoptedStyleSheets, Media Chrome isShimmed, type errors...). Each fix adds complexity. The pattern is clear: DOM simulation will always be a leaky abstraction.
Verdict: Rejected. We've already spent more time debugging happy-dom than the original implementation took. The fundamental approach is wrong.
Puppeteer is the older headless Chrome library. Playwright is its spiritual successor with better cross-browser support, auto-wait capabilities, and TypeScript-first API. Since the project already uses Playwright for e2e tests, there's no reason to add a second browser automation library.
Verdict: Rejected. Playwright is already a project dependency.
Take a PNG screenshot of each component and display as an image.
Verdict: Rejected for Phase 3. Screenshots lose semantic information (can't select text, can't inspect structure, can't adapt to theme). HTML snapshots are more useful and smaller. Screenshots could be added as a Phase 4 enhancement.
- ADR-0029 — Happy DOM for DOM Simulation (superseded)
- ADR-0031 — Hub v2 Component Browser
- SOP v0.19.0 — Platform Hub (Phase 1 & 2)
- Playwright — Browser automation framework