feat(accordion): SSR + DSD via @lit-labs/ssr; collapse to single mp-accordion WC#370
feat(accordion): SSR + DSD via @lit-labs/ssr; collapse to single mp-accordion WC#370PieterjanDeClippel wants to merge 1 commit into
Conversation
…ccordion WC Pilot for the workspace-wide WC SSR migration (PRD: docs/prd/wc-ssr-lit-labs.md). Restores "visible-without-JS" rendering for the Lit-backed accordion via declarative shadow DOM emitted server-side, and consolidates the accordion into a single composite web component so styles and cascade live in one shadow scope. Architectural shape (mirrors mp-tab-control): - One <mp-accordion> WC owns the full structure for every tab in its shadow. - <bs-accordion-tab> is a marker host carrying data-tab-id / is-active / disabled / slot="X-content"; no shadow of its own. - Header content authored via *bsAccordionTabHeader structural directive (replaces the old <bs-accordion-tab-header> component). SSR pipeline: - apps/ng-bootstrap-demo/lit-ssr-middleware.ts post-processes Angular SSR output, instantiates LitElementRenderer per registered tag, splices the <template shadowrootmode> DSD as the first child of the host. - Pins WC class refs at boot and re-registers per request because @angular/ssr swaps the global customElements registry per request. - Client loads @lit-labs/ssr-client/lit-element-hydrate-support.js + the DSD polyfill (conditionally). - mp-accordion overrides createRenderRoot to skip hydrate-support's _$AG flag (the SSR radio+CSS markup doesn't match the JS-driven button markup; we always want a fresh render not a hydrate). Theming via custom properties: - Bootstrap's accordion module is loaded globally so .accordion-class declarations attach to <bs-accordion> in light DOM (defaults). - Inside the shadow, .accordion redeclares each --bs-accordion-* var to `inherit` so consumer overrides on light-DOM ancestors flow through. - Active state reads --bs-accordion-active-bg / --color (not hardcoded --bs-primary-bg-subtle) so demo themes survive the open state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces Lit SSR post-processing middleware to enrich Angular SSR output with Declarative Shadow DOM (DSD) for Lit web components, specifically migrating the accordion component to a framework-agnostic Lit-based implementation (mp-accordion). The Angular wrapper components are simplified to act as content-projection layers, utilizing a new structural directive *bsAccordionTabHeader for header templates. The review feedback highlights a critical race condition in mp-accordion.ts where SSR state restoration can fail if the custom element is upgraded synchronously before its light-DOM children are parsed. Actionable suggestions are provided to defer state restoration by snapshotting the SSR checked state immediately and applying it asynchronously during tab refresh.
| override connectedCallback(): void { | ||
| // Hydration opt-out for the SSR branch. Snapshot the SSR <input>'s | ||
| // checked state back onto the light-DOM markers so the client render | ||
| // path picks up the same active set. The shadow content is wiped in | ||
| // `createRenderRoot` below — has to happen there (not here) because | ||
| // `@lit-labs/ssr-client/lit-element-hydrate-support` patches | ||
| // `createRenderRoot` to detect an existing shadow root and flip its | ||
| // `_$AG` flag, switching the first `update()` from `render()` to | ||
| // `hydrate()`. Overriding `createRenderRoot` ourselves wins on the | ||
| // prototype chain (subclass beats global patch) and prevents that flag | ||
| // from ever being set. | ||
| if (this.hasAttribute('is-server-side')) { | ||
| this.snapshotSsrCheckedState(); | ||
| this.removeAttribute('is-server-side'); | ||
| this.isServerSide = false; | ||
| } |
There was a problem hiding this comment.
Race Condition in SSR State Restoration
When the custom element is upgraded synchronously during HTML parsing (which happens if the JS bundle is already loaded or cached), connectedCallback runs as soon as the opening <mp-accordion> tag is parsed. At this point, the light-DOM children (the <bs-accordion-tab> markers) have not been parsed yet, so this.querySelector in snapshotSsrCheckedState will return null, and the SSR active state will be lost.
To fix this, we should read the checked state from the shadow DOM immediately in connectedCallback (before it gets wiped in createRenderRoot) and store the active IDs in an instance property. Then, we can apply these active attributes to the light-DOM markers asynchronously when they are parsed and detected in refreshTabs().
| override connectedCallback(): void { | |
| // Hydration opt-out for the SSR branch. Snapshot the SSR <input>'s | |
| // checked state back onto the light-DOM markers so the client render | |
| // path picks up the same active set. The shadow content is wiped in | |
| // `createRenderRoot` below — has to happen there (not here) because | |
| // `@lit-labs/ssr-client/lit-element-hydrate-support` patches | |
| // `createRenderRoot` to detect an existing shadow root and flip its | |
| // `_$AG` flag, switching the first `update()` from `render()` to | |
| // `hydrate()`. Overriding `createRenderRoot` ourselves wins on the | |
| // prototype chain (subclass beats global patch) and prevents that flag | |
| // from ever being set. | |
| if (this.hasAttribute('is-server-side')) { | |
| this.snapshotSsrCheckedState(); | |
| this.removeAttribute('is-server-side'); | |
| this.isServerSide = false; | |
| } | |
| private ssrActiveTabIds: string[] = []; | |
| override connectedCallback(): void { | |
| // Hydration opt-out for the SSR branch. Snapshot the SSR <input>'s | |
| // checked state back onto the light-DOM markers so the client render | |
| // path picks up the same active set. The shadow content is wiped in | |
| // `createRenderRoot` below — has to happen there (not here) because | |
| // `@lit-labs/ssr-client/lit-element-hydrate-support` patches | |
| // `createRenderRoot` to detect an existing shadow root and flip its | |
| // `_$AG` flag, switching the first `update()` from `render()` to | |
| // `hydrate()`. Overriding `createRenderRoot` ourselves wins on the | |
| // prototype chain (subclass beats global patch) and prevents that flag | |
| // from ever being set. | |
| if (this.hasAttribute('is-server-side')) { | |
| this.ssrActiveTabIds = this.getSsrCheckedState(); | |
| this.removeAttribute('is-server-side'); | |
| this.isServerSide = false; | |
| } |
| /** | ||
| * Read each SSR-rendered <input>'s `checked` and reflect it back onto the | ||
| * corresponding light-DOM marker's `is-active` attribute. Lets consumer | ||
| * frameworks observe the post-hydration state via normal attribute reads. | ||
| */ | ||
| private snapshotSsrCheckedState(): void { | ||
| const inputs = this.shadowRoot?.querySelectorAll<HTMLInputElement>( | ||
| 'input[type="radio"], input[type="checkbox"]', | ||
| ); | ||
| inputs?.forEach((input) => { | ||
| const id = input.id; | ||
| const marker = this.querySelector(`[data-tab-id="${id}"]`); | ||
| if (!marker) return; | ||
| if (input.checked && !marker.hasAttribute('is-active')) { | ||
| marker.setAttribute('is-active', ''); | ||
| } else if (!input.checked && marker.hasAttribute('is-active')) { | ||
| marker.removeAttribute('is-active'); | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
Replace snapshotSsrCheckedState with getSsrCheckedState
To support deferred state restoration and avoid the race condition where light-DOM children are not yet parsed, replace snapshotSsrCheckedState with a helper that reads the checked state and returns the active IDs.
/**
* Read each SSR-rendered <input>'s `checked` state and return the active IDs.
*/
private getSsrCheckedState(): string[] {
const inputs = this.shadowRoot?.querySelectorAll<HTMLInputElement>(
'input[type="radio"], input[type="checkbox"]',
);
const activeIds: string[] = [];
inputs?.forEach((input) => {
if (input.checked) {
activeIds.push(input.id);
}
});
return activeIds;
}| private refreshTabs(): void { | ||
| const found = new Map<string, TabInfo>(); | ||
| for (const child of Array.from(this.children)) { | ||
| const id = child.getAttribute('data-tab-id'); | ||
| if (!id) continue; | ||
| if (!found.has(id)) { | ||
| found.set(id, { | ||
| id, | ||
| isActive: child.hasAttribute('is-active'), | ||
| disabled: child.hasAttribute('disabled'), | ||
| }); | ||
| } | ||
| } | ||
| this.tabs = Array.from(found.values()); | ||
| this.requestUpdate(); | ||
| } |
There was a problem hiding this comment.
Apply SSR Active State in refreshTabs
Update refreshTabs to apply the stored SSR active state to the light-DOM markers as soon as they are parsed and detected.
private refreshTabs(): void {
const found = new Map<string, TabInfo>();
for (const child of Array.from(this.children)) {
const id = child.getAttribute('data-tab-id');
if (!id) continue;
if (this.ssrActiveTabIds.includes(id)) {
child.setAttribute('is-active', '');
this.ssrActiveTabIds = this.ssrActiveTabIds.filter((activeId) => activeId !== id);
}
if (!found.has(id)) {
found.set(id, {
id,
isActive: child.hasAttribute('is-active'),
disabled: child.hasAttribute('disabled'),
});
}
}
this.tabs = Array.from(found.values());
this.requestUpdate();
}
Pilot for the workspace-wide WC SSR migration (PRD:
docs/prd/wc-ssr-lit-labs.md).Restores "visible-without-JS" rendering for the Lit-backed accordion via declarative shadow DOM emitted server-side, and consolidates the accordion into a single composite web component so styles and cascade live in one shadow scope.
Architectural shape (mirrors
mp-tab-control)<mp-accordion>WC owns the full structure for every tab in its shadow.<bs-accordion-tab>is a marker host carryingdata-tab-id/is-active/disabled/slot="X-content"; no shadow of its own.*bsAccordionTabHeaderstructural directive (replaces the old<bs-accordion-tab-header>component).SSR pipeline
apps/ng-bootstrap-demo/lit-ssr-middleware.tspost-processes Angular SSR output, instantiatesLitElementRendererper registered tag, and splices the<template shadowrootmode>DSD as the first child of the host.@angular/ssrswaps the globalcustomElementsregistry per request.@lit-labs/ssr-client/lit-element-hydrate-support.js+ the DSD polyfill (conditionally).mp-accordionoverridescreateRenderRootto skip hydrate-support's_$AGflag (the SSR radio+CSS markup doesn't match the JS-driven button markup; we always want a fresh render, not a hydrate).Theming via custom properties
.accordion-classdeclarations attach to<bs-accordion>in light DOM (defaults)..accordionredeclares each--bs-accordion-*var toinheritso consumer overrides on light-DOM ancestors flow through.--bs-accordion-active-bg/--color(not hardcoded--bs-primary-bg-subtle) so demo themes survive the open state.🤖 Generated with Claude Code