Skip to content

feat(accordion): SSR + DSD via @lit-labs/ssr; collapse to single mp-accordion WC#370

Draft
PieterjanDeClippel wants to merge 1 commit into
masterfrom
feat/wc-ssr-accordion-pilot
Draft

feat(accordion): SSR + DSD via @lit-labs/ssr; collapse to single mp-accordion WC#370
PieterjanDeClippel wants to merge 1 commit into
masterfrom
feat/wc-ssr-accordion-pilot

Conversation

@PieterjanDeClippel

Copy link
Copy Markdown
Member

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, and 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.

🤖 Generated with Claude Code

…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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +68 to +83
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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().

Suggested change
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;
}

Comment on lines +128 to +147
/**
* 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');
}
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
  }

Comment on lines +149 to +164
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();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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();
  }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant