messenzy-widget/src/mount.ts
Serge RAKOTO HARRY-NAIVO f74ff56fc4 feat(mount): extract mountWidget + applyStyles helpers with shadow support
mountWidget centralizes both classic and shadow mounting behind a single
entry point. The shadow path attaches an open shadow root, injects CSS via
constructable stylesheets (with a deduped <style>-tag fallback for jsdom
and Safari < 16.4), and renders Preact into a stable inner wrapper so the
sibling style node is not clobbered by render() diffs.

applyStyles is exported separately so the fallback branch can be exercised
directly in unit tests without a full mount round trip — the constructable
path runs only in real browsers.

Idempotency on the host element and shadow root means repeated boots
(HMR, accidental double-load) do not duplicate DOM nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:55:02 +02:00

91 lines
2.8 KiB
TypeScript

import { h, render } from 'preact';
import { Widget } from './ui/widget.js';
import type { WidgetConfig } from './config.js';
export type MountMode = 'classic' | 'shadow';
export type MountHandle = {
root: HTMLElement;
shadowRoot?: ShadowRoot;
};
const ROOT_ID = 'messenzy-root';
const SHADOW_INNER_ID = 'messenzy-shadow-inner';
const SHADOW_STYLE_ID = 'messenzy-shadow-style';
/**
* Mount the widget into the DOM.
*
* - classic mode: renders Preact directly into the host div in the light DOM.
* The caller is responsible for ensuring CSS is in document.head.
* - shadow mode: attaches an open shadow root, injects CSS inside the shadow
* tree (constructable stylesheets when supported, <style> fallback otherwise),
* and renders Preact into a stable wrapper inside the shadow root.
*
* Both modes are idempotent: calling twice reuses the existing #messenzy-root
* (and its shadow root, in shadow mode) rather than recreating it.
*/
export function mountWidget(
config: WidgetConfig,
cssText: string,
mode: MountMode,
): MountHandle {
const root = resolveOrCreateRoot();
if (mode !== 'shadow') {
render(h(Widget, { config }), root);
return { root };
}
const shadow = root.shadowRoot ?? root.attachShadow({ mode: 'open' });
applyStyles(shadow, cssText);
// Render into a stable inner wrapper so Preact's render() does not clobber
// the sibling <style> node when it diffs children.
let inner = shadow.getElementById(SHADOW_INNER_ID) as HTMLElement | null;
if (!inner) {
inner = document.createElement('div');
inner.id = SHADOW_INNER_ID;
shadow.appendChild(inner);
}
render(h(Widget, { config }), inner);
return { root, shadowRoot: shadow };
}
/**
* Inject CSS into a shadow root.
*
* Prefers constructable stylesheets (`adoptedStyleSheets`) — faster, no DOM
* mutation, sheet shareable across instances. Falls back to a deduped <style>
* child when constructable sheets are unavailable (jsdom, Safari < 16.4).
*/
export function applyStyles(shadow: ShadowRoot, cssText: string): void {
if (supportsConstructableStylesheets()) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(cssText);
shadow.adoptedStyleSheets = [sheet];
return;
}
if (shadow.getElementById(SHADOW_STYLE_ID)) return;
const style = document.createElement('style');
style.id = SHADOW_STYLE_ID;
style.textContent = cssText;
shadow.prepend(style);
}
function resolveOrCreateRoot(): HTMLElement {
const existing = document.getElementById(ROOT_ID);
if (existing) return existing;
const root = document.createElement('div');
root.id = ROOT_ID;
document.body.appendChild(root);
return root;
}
function supportsConstructableStylesheets(): boolean {
return (
typeof CSSStyleSheet !== 'undefined' &&
'replaceSync' in CSSStyleSheet.prototype
);
}