Skip to content

fix: enable hydration for Lit elements used as raw HTML tags#7

Open
piotrekwitkowski wants to merge 1 commit intoSemantic-Org:mainfrom
cumulus-ui:fix/hydration-without-client-directives
Open

fix: enable hydration for Lit elements used as raw HTML tags#7
piotrekwitkowski wants to merge 1 commit intoSemantic-Org:mainfrom
cumulus-ui:fix/hydration-without-client-directives

Conversation

@piotrekwitkowski
Copy link
Copy Markdown

@piotrekwitkowski piotrekwitkowski commented Apr 10, 2026

Fixes #6

Problem

Hydration support is injected via before-hydration, which only fires for pages with client:* directives. Pages using Lit elements as raw HTML tags (<my-element>) get SSR'd with DSD but never hydrated, causing duplicate shadow DOM content.

On top of that, globalThis.litElementHydrateSupport is a one-shot callback — LitElement checks for it once during class initialization. injectScript('page') creates a separate module entry point, and Vite may evaluate lit before the callback is set, so it's too late.

Approach

Three pieces working together:

1. hydration-support-global.js — injected as head-inline

Synchronous <script> that sets globalThis.litElementHydrateSupport before any module loads. Patches createRenderRoot (reuse DSD shadow roots, set _$AG flag), connectedCallback (respect defer-hydration), and attributeChangedCallback (resume on attribute removal).

2. hydration-support.js — loaded via Vite plugin

Patches LitElement.prototype.update() to choose between hydrate() and replaceChildren() + render(). The decision:

  • Shadow root contains children with defer-hydration → replace
  • Any non-reflected property has a non-default value → replace
  • Otherwise → hydrate

Also removes defer-hydration from document-level elements via queueMicrotask, and stores __childPart + resets renderOptions.renderBefore to prevent duplicate content on re-renders.

3. Vite transform plugin

Prepends the hydration-support.js import into Astro <script> modules (matched by type=script in the virtual module ID). This ensures the hydration patches and the user's component import end up in the same Vite chunk, sharing one LitElement prototype. Skips server-side transforms via options.ssr.

At build time, we check whether @lit-labs/ssr-client already handles deferred hydration natively. If it does, all of the above is skipped and we fall back to the original before-hydration import.

Testing

  • All 10 existing tests pass
  • 28 Playwright tests (POC): static, dynamic, nested (3+ levels), re-render after property change, @State() interaction, hydration flag verification
  • 28 real component pages on a docs site: 27 pass, 1 pre-existing MDX bug (unrelated)
  • Dev mode: no duplicates, interaction works
  • Production build: single JS chunk, 1 LitElement instance

piotrekwitkowski added a commit to cumulus-ui/cumulus-ui.github.io that referenced this pull request Apr 10, 2026
Points @semantic-ui/astro-lit at our fork which fixes hydration for
Lit elements used as raw HTML tags without client:* directives.
Upstream PR: Semantic-Org/Astro-Lit#7
@piotrekwitkowski piotrekwitkowski force-pushed the fix/hydration-without-client-directives branch 8 times, most recently from 7c0c653 to 3292a32 Compare April 10, 2026 02:57
@piotrekwitkowski
Copy link
Copy Markdown
Author

Update from testing in production (cumulus-ui docs site, 83 pages, 80+ Lit components):

Static components (button, container, header, link, badge, etc.) hydrate perfectly — SSR output matches client render, hydrate() adopts the DSD content, zero flicker.

Dynamic components (side-navigation, breadcrumb-group, table — those with attribute:false properties set via inline scripts) still produce duplicate shadow DOM content after hydration. The replaceSSRContent path clears and re-renders the parent, but nested child components that were already upgraded produce duplicate .root divs.

The Vite plugin approach (auto-prepending hydration-support.js into Astro script chunks) works correctly. The SSR guard (skipping transform during SSR build) works. The _$AG flag fix works. The remaining issue is specifically in how replaceSSRContent interacts with already-upgraded nested Lit elements.

Hydration support was injected via 'before-hydration', which only fires
for pages with Astro client:* directives. Pages using Lit elements as
raw HTML tags never got hydration, causing duplicate shadow DOM content.

The fix splits hydration into two phases:

1. A synchronous inline script in <head> that sets up the
   globalThis.litElementHydrateSupport callback before any module
   script can import lit.

2. A page-level module that patches LitElement.update() to handle
   elements with defer-hydration whose SSR output may not match the
   client render (replaceChildren + fresh render instead of hydrate).

At build time, we check whether @lit-labs/ssr-client already handles
deferred hydration natively. If it does, both of the above are skipped
and we fall back to the original before-hydration import.
@piotrekwitkowski piotrekwitkowski force-pushed the fix/hydration-without-client-directives branch from 3292a32 to 997f6d9 Compare April 10, 2026 03:28
@jlukic
Copy link
Copy Markdown
Member

jlukic commented Apr 11, 2026

Thanks I'll take a look sometime next week.

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.

Bug: Hydration support not loaded when Lit elements are used as raw HTML tags (no client:* directives)

2 participants