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>
This commit is contained in:
parent
8d0696584c
commit
f74ff56fc4
90
src/mount.ts
Normal file
90
src/mount.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
120
tests/unit/mount.test.ts
Normal file
120
tests/unit/mount.test.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Stub the Widget component so mountWidget does not boot the WebSocket
|
||||||
|
// transport (jsdom has no WebSocket implementation). We only verify mount
|
||||||
|
// mechanics here — Widget rendering is a separate concern.
|
||||||
|
vi.mock('@/ui/widget', () => ({
|
||||||
|
Widget: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { mountWidget, applyStyles } from '@/mount';
|
||||||
|
import type { WidgetConfig } from '@/config';
|
||||||
|
|
||||||
|
const baseConfig: WidgetConfig = {
|
||||||
|
botId: 'test-bot',
|
||||||
|
apiKey: 'test-key',
|
||||||
|
serverUrl: 'https://test.local',
|
||||||
|
position: 'bottom-right',
|
||||||
|
shadow: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_CSS = '.messenzy-widget { color: tomato; }';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.head.innerHTML = '';
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountWidget — classic mode', () => {
|
||||||
|
it('creates #messenzy-root in document.body when absent', () => {
|
||||||
|
mountWidget(baseConfig, TEST_CSS, 'classic');
|
||||||
|
const root = document.getElementById('messenzy-root');
|
||||||
|
expect(root).not.toBeNull();
|
||||||
|
expect(root?.parentElement).toBe(document.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses an existing #messenzy-root', () => {
|
||||||
|
const existing = document.createElement('div');
|
||||||
|
existing.id = 'messenzy-root';
|
||||||
|
document.body.appendChild(existing);
|
||||||
|
const handle = mountWidget(baseConfig, TEST_CSS, 'classic');
|
||||||
|
expect(handle.root).toBe(existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a handle without shadowRoot', () => {
|
||||||
|
const handle = mountWidget(baseConfig, TEST_CSS, 'classic');
|
||||||
|
expect(handle.shadowRoot).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not attach a shadow root to the host', () => {
|
||||||
|
mountWidget(baseConfig, TEST_CSS, 'classic');
|
||||||
|
const root = document.getElementById('messenzy-root');
|
||||||
|
expect(root?.shadowRoot).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountWidget — shadow mode', () => {
|
||||||
|
it('creates the host and attaches an open shadow root', () => {
|
||||||
|
const handle = mountWidget(baseConfig, TEST_CSS, 'shadow');
|
||||||
|
const root = document.getElementById('messenzy-root');
|
||||||
|
expect(root).not.toBeNull();
|
||||||
|
expect(root?.shadowRoot).not.toBeNull();
|
||||||
|
expect(handle.shadowRoot).toBe(root?.shadowRoot);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses an already-attached shadow root (HMR/idempotency)', () => {
|
||||||
|
const existing = document.createElement('div');
|
||||||
|
existing.id = 'messenzy-root';
|
||||||
|
const preAttached = existing.attachShadow({ mode: 'open' });
|
||||||
|
document.body.appendChild(existing);
|
||||||
|
const handle = mountWidget(baseConfig, TEST_CSS, 'shadow');
|
||||||
|
expect(handle.shadowRoot).toBe(preAttached);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects styles into the shadow root, not document.head', () => {
|
||||||
|
mountWidget(baseConfig, TEST_CSS, 'shadow');
|
||||||
|
expect(document.getElementById('messenzy-styles')).toBeNull();
|
||||||
|
const shadow = document.getElementById('messenzy-root')?.shadowRoot;
|
||||||
|
const styleNode = shadow?.querySelector('style');
|
||||||
|
expect(styleNode).not.toBeNull();
|
||||||
|
expect(styleNode?.textContent).toContain('tomato');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the <style> intact when Preact renders (uses inner wrapper)', () => {
|
||||||
|
mountWidget(baseConfig, TEST_CSS, 'shadow');
|
||||||
|
const shadow = document.getElementById('messenzy-root')?.shadowRoot;
|
||||||
|
// The shadow root must contain both the style node AND a separate
|
||||||
|
// wrapper for the Preact render target — otherwise render() would
|
||||||
|
// clobber the <style>.
|
||||||
|
expect(shadow?.querySelector('style')).not.toBeNull();
|
||||||
|
expect(shadow?.children.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyStyles', () => {
|
||||||
|
let shadow: ShadowRoot;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
shadow = host.attachShadow({ mode: 'open' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends a <style> node into the shadow root (jsdom fallback path)', () => {
|
||||||
|
applyStyles(shadow, TEST_CSS);
|
||||||
|
const style = shadow.querySelector('style');
|
||||||
|
expect(style).not.toBeNull();
|
||||||
|
expect(style?.textContent).toBe(TEST_CSS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — repeated calls keep one <style>', () => {
|
||||||
|
applyStyles(shadow, TEST_CSS);
|
||||||
|
applyStyles(shadow, TEST_CSS);
|
||||||
|
expect(shadow.querySelectorAll('style')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The constructable-stylesheets path (`adoptedStyleSheets`) is exercised
|
||||||
|
// in real browsers with full CSSOM support. jsdom does not implement it,
|
||||||
|
// so the unit suite verifies only the <style>-fallback branch. Manual
|
||||||
|
// smoke-test in a real browser confirms the modern path.
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user