Skip to content

Commit 997f6d9

Browse files
fix: enable hydration for Lit elements used as raw HTML tags
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.
1 parent 0e6e5db commit 997f6d9

File tree

6 files changed

+253
-16
lines changed

6 files changed

+253
-16
lines changed

dist/index.js

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
// src/index.ts
22
import { readFileSync } from "node:fs";
3-
function getViteConfiguration() {
3+
function litHydrationPlugin() {
44
return {
5+
name: "astro-lit-hydration",
6+
transform(code, id, options) {
7+
if (options?.ssr) return;
8+
if (!id.includes("type=script")) return;
9+
if (code.includes("hydration-support")) return;
10+
return `import '@semantic-ui/astro-lit/hydration-support.js';
11+
` + code;
12+
}
13+
};
14+
}
15+
function getViteConfiguration(usePlugin) {
16+
return {
17+
plugins: usePlugin ? [litHydrationPlugin()] : [],
518
optimizeDeps: {
619
include: [
720
"@semantic-ui/astro-lit/dist/client.js",
821
"@semantic-ui/astro-lit/client-shim.js",
922
"@semantic-ui/astro-lit/hydration-support.js",
10-
"@webcomponents/template-shadowroot/template-shadowroot.js",
11-
"@lit-labs/ssr-client/lit-element-hydrate-support.js"
23+
"@webcomponents/template-shadowroot/template-shadowroot.js"
1224
],
1325
exclude: ["@semantic-ui/astro-lit/server.js"]
1426
},
@@ -17,6 +29,15 @@ function getViteConfiguration() {
1729
}
1830
};
1931
}
32+
function litHandlesDeferredHydration() {
33+
try {
34+
const resolved = import.meta.resolve("@lit-labs/ssr-client/lit-element-hydrate-support.js");
35+
const src = readFileSync(new URL(resolved), "utf-8");
36+
return src.includes("skip-hydration") || src.includes("deferredBySSR");
37+
} catch {
38+
return false;
39+
}
40+
}
2041
function getContainerRenderer() {
2142
return {
2243
name: "@semantic-ui/astro-lit",
@@ -32,14 +53,22 @@ function index_default() {
3253
"head-inline",
3354
readFileSync(new URL("../client-shim.min.js", import.meta.url), { encoding: "utf-8" })
3455
);
35-
injectScript("before-hydration", `import '@semantic-ui/astro-lit/hydration-support.js';`);
56+
const litNative = litHandlesDeferredHydration();
57+
if (litNative) {
58+
injectScript("before-hydration", `import '@lit-labs/ssr-client/lit-element-hydrate-support.js';`);
59+
} else {
60+
injectScript(
61+
"head-inline",
62+
readFileSync(new URL("../hydration-support-global.js", import.meta.url), { encoding: "utf-8" })
63+
);
64+
}
3665
addRenderer({
3766
name: "@semantic-ui/astro-lit",
3867
serverEntrypoint: "@semantic-ui/astro-lit/server.js",
3968
clientEntrypoint: "@semantic-ui/astro-lit/dist/client.js"
4069
});
4170
updateConfig({
42-
vite: getViteConfiguration()
71+
vite: getViteConfiguration(!litNative)
4372
});
4473
},
4574
"astro:build:setup": ({ vite, target }) => {

hydration-support-global.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Inline script that sets up globalThis.litElementHydrateSupport synchronously.
2+
// This MUST run before any module script that imports 'lit', because LitElement
3+
// checks for this global during class definition and it's a one-shot opportunity.
4+
//
5+
// This sets up the STRUCTURAL patches (shadow root reuse, defer-hydration).
6+
// The RENDER patches (hydrate vs render on update) must be loaded from the same
7+
// <script> as the component library to share the same Lit instance — see
8+
// hydration-support.js.
9+
"use strict";
10+
globalThis.litElementHydrateSupport = function(ref) {
11+
var LitElement = ref.LitElement;
12+
13+
// Make LitElement observe the defer-hydration attribute
14+
var origObserved = Object.getOwnPropertyDescriptor(
15+
Object.getPrototypeOf(LitElement), 'observedAttributes'
16+
).get;
17+
Object.defineProperty(LitElement, 'observedAttributes', {
18+
get: function() { return [].concat(origObserved.call(this), ['defer-hydration']); }
19+
});
20+
21+
// Defer connectedCallback when defer-hydration is present
22+
var origConnected = LitElement.prototype.connectedCallback;
23+
LitElement.prototype.connectedCallback = function() {
24+
if (!this.hasAttribute('defer-hydration')) origConnected.call(this);
25+
};
26+
27+
// Resume hydration when defer-hydration attribute is removed
28+
var origAttrChanged = LitElement.prototype.attributeChangedCallback;
29+
LitElement.prototype.attributeChangedCallback = function(name, oldVal, newVal) {
30+
if (name === 'defer-hydration' && newVal === null) origConnected.call(this);
31+
origAttrChanged.call(this, name, oldVal, newVal);
32+
};
33+
34+
// Reuse existing DSD shadow root instead of calling attachShadow
35+
var origCreateRenderRoot = LitElement.prototype.createRenderRoot;
36+
LitElement.prototype.createRenderRoot = function() {
37+
if (this.shadowRoot) {
38+
this._$AG = true;
39+
return this.shadowRoot;
40+
}
41+
return origCreateRenderRoot.call(this);
42+
};
43+
};

hydration-support.js

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,105 @@
11
// @ts-check
2-
import '@lit-labs/ssr-client/lit-element-hydrate-support.js';
2+
//
3+
// This module is only loaded when Lit doesn't handle deferred hydration
4+
// natively (checked at build time in src/index.ts). The structural patches
5+
// (shadow root reuse, defer-hydration attribute observation) are set up by
6+
// the inline hydration-support-global.js. This module adds the render-time
7+
// patches that need lit-html imports.
8+
9+
import { LitElement } from 'lit';
10+
import { render } from 'lit-html';
11+
import { hydrate } from '@lit-labs/ssr-client';
12+
13+
// Patch update() to use hydrate() on first render when a DSD shadow root
14+
// exists, and to do a clean replace when the SSR output can't match the
15+
// client render. Workaround for lit/lit#4822.
16+
17+
const origUpdate = Object.getPrototypeOf(LitElement.prototype).update;
18+
19+
function replaceSSRContent(element, templateResult) {
20+
const root = element.shadowRoot;
21+
root.replaceChildren();
22+
23+
const ctor = /** @type {typeof LitElement} */ (element.constructor);
24+
if (ctor.elementStyles) {
25+
const sheets = [];
26+
for (const s of ctor.elementStyles) {
27+
if (s instanceof CSSStyleSheet) {
28+
sheets.push(s);
29+
} else if (s.styleSheet) {
30+
sheets.push(s.styleSheet);
31+
}
32+
}
33+
if (sheets.length) {
34+
root.adoptedStyleSheets = sheets;
35+
}
36+
}
37+
38+
// Reset renderBefore since we cleared the shadow root
39+
element.renderOptions.renderBefore = root.firstChild;
40+
element.__childPart = render(templateResult, root, element.renderOptions);
41+
}
42+
43+
// The SSR renderer only has access to reflected attributes. If any non-
44+
// reflected property holds a value that would change the render output
45+
// compared to the default, hydrate() will hit a mismatch. Similarly, if
46+
// the shadow root contains child elements with defer-hydration, the SSR'd
47+
// DOM structure won't match the client's template expectations.
48+
function canHydrate(element) {
49+
// Shadow root contains deferred children whose DOM won't match
50+
if (element.shadowRoot?.querySelector('[defer-hydration]')) return false;
51+
52+
// Check non-reflected properties for non-default values. These couldn't
53+
// be serialized to HTML, so the SSR output used defaults only.
54+
const ctor = /** @type {typeof LitElement} */ (element.constructor);
55+
if (ctor.elementProperties) {
56+
for (const [name, options] of ctor.elementProperties) {
57+
if (options.reflect) continue;
58+
const value = element[name];
59+
if (value === undefined || value === null || value === '' || value === false) continue;
60+
if (Array.isArray(value) && value.length === 0) continue;
61+
if (typeof value === 'function') return false;
62+
if (Array.isArray(value) && value.length > 0) return false;
63+
if (typeof value === 'object' && Object.keys(value).length > 0) return false;
64+
}
65+
}
66+
67+
return true;
68+
}
69+
70+
LitElement.prototype.update = function update(changedProperties) {
71+
const templateResult = this.render();
72+
origUpdate.call(this, changedProperties);
73+
74+
if (this._$AG) {
75+
this._$AG = false;
76+
77+
if (canHydrate(this)) {
78+
for (let i = this.attributes.length - 1; i >= 0; i--) {
79+
const attr = this.attributes[i];
80+
if (attr.name.startsWith('hydrate-internals-')) {
81+
this.removeAttribute(attr.name.slice(18));
82+
this.removeAttribute(attr.name);
83+
}
84+
}
85+
this.__childPart = hydrate(templateResult, this.renderRoot, this.renderOptions);
86+
} else {
87+
replaceSSRContent(this, templateResult);
88+
}
89+
} else {
90+
this.__childPart = render(templateResult, this.renderRoot, this.renderOptions);
91+
}
92+
};
93+
94+
// Remove defer-hydration from SSR'd elements so they can initialize.
95+
// queueMicrotask so inline scripts that set properties run first.
96+
// Guard against server-side execution where document doesn't exist.
97+
if (typeof document !== 'undefined') {
98+
queueMicrotask(() => {
99+
const deferred = document.querySelectorAll('[defer-hydration]');
100+
if (deferred.length === 0) return;
101+
deferred.forEach((el) => {
102+
el.removeAttribute('defer-hydration');
103+
});
104+
});
105+
}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@
2222
"./client-shim.js": "./client-shim.js",
2323
"./dist/client.js": "./dist/client.js",
2424
"./hydration-support.js": "./hydration-support.js",
25+
"./hydration-support-global.js": "./hydration-support-global.js",
2526
"./package.json": "./package.json"
2627
},
2728
"files": [
2829
"dist",
2930
"client-shim.js",
3031
"client-shim.min.js",
3132
"hydration-support.js",
33+
"hydration-support-global.js",
3234
"server.js",
3335
"server.d.ts",
3436
"server-shim.js"

src/index.ts

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,45 @@
11
import { readFileSync } from 'node:fs';
22
import type { AstroIntegration, ContainerRenderer } from 'astro';
3+
import type { Plugin } from 'vite';
34

4-
function getViteConfiguration() {
5+
/**
6+
* Vite plugin that prepends the hydration support import to any module
7+
* that imports from 'lit' or 'lit-element'. This ensures the hydration
8+
* patches and the component definitions end up in the same Vite chunk,
9+
* sharing one LitElement prototype.
10+
*
11+
* Without this, injectScript('page') creates a separate entry point
12+
* that Vite may split into a different chunk — the patches would target
13+
* a different LitElement class than the one components use.
14+
*/
15+
function litHydrationPlugin(): Plugin {
516
return {
17+
name: 'astro-lit-hydration',
18+
transform(code, id, options) {
19+
// Skip server-side transforms (both dev SSR and production build)
20+
if (options?.ssr) return;
21+
// Target only Astro <script> tags. Astro compiles them into
22+
// virtual modules with IDs like:
23+
// /path/Layout.astro?astro&type=script&index=0&lang.ts
24+
// Skip frontmatter modules (?id=N) and style modules (?type=style).
25+
if (!id.includes('type=script')) return;
26+
if (code.includes('hydration-support')) return;
27+
// Prepend hydration support so it's in the same Vite chunk as the
28+
// user's component import — sharing one LitElement prototype.
29+
return `import '@semantic-ui/astro-lit/hydration-support.js';\n` + code;
30+
},
31+
};
32+
}
33+
34+
function getViteConfiguration(usePlugin: boolean) {
35+
return {
36+
plugins: usePlugin ? [litHydrationPlugin()] : [],
637
optimizeDeps: {
738
include: [
839
'@semantic-ui/astro-lit/dist/client.js',
940
'@semantic-ui/astro-lit/client-shim.js',
1041
'@semantic-ui/astro-lit/hydration-support.js',
1142
'@webcomponents/template-shadowroot/template-shadowroot.js',
12-
'@lit-labs/ssr-client/lit-element-hydrate-support.js',
1343
],
1444
exclude: ['@semantic-ui/astro-lit/server.js'],
1545
},
@@ -19,6 +49,20 @@ function getViteConfiguration() {
1949
};
2050
}
2151

52+
/**
53+
* Check whether the installed @lit-labs/ssr-client already handles deferred
54+
* hydration natively. If so, we skip our workaround entirely.
55+
*/
56+
function litHandlesDeferredHydration(): boolean {
57+
try {
58+
const resolved = import.meta.resolve('@lit-labs/ssr-client/lit-element-hydrate-support.js');
59+
const src = readFileSync(new URL(resolved), 'utf-8');
60+
return src.includes('skip-hydration') || src.includes('deferredBySSR');
61+
} catch {
62+
return false;
63+
}
64+
}
65+
2266
export function getContainerRenderer(): ContainerRenderer {
2367
return {
2468
name: '@semantic-ui/astro-lit',
@@ -31,22 +75,38 @@ export default function (): AstroIntegration {
3175
name: '@semantic-ui/astro-lit',
3276
hooks: {
3377
'astro:config:setup': ({ updateConfig, addRenderer, injectScript }) => {
34-
// Inject the necessary polyfills on every page (inlined for speed).
78+
// DSD polyfill for browsers that don't support declarative shadow DOM.
3579
injectScript(
3680
'head-inline',
3781
readFileSync(new URL('../client-shim.min.js', import.meta.url), { encoding: 'utf-8' })
3882
);
39-
// Inject the hydration code, before a component is hydrated.
40-
injectScript('before-hydration', `import '@semantic-ui/astro-lit/hydration-support.js';`);
41-
// Add the lit renderer so that Astro can understand lit components.
83+
84+
const litNative = litHandlesDeferredHydration();
85+
86+
if (litNative) {
87+
// Lit handles deferred hydration natively.
88+
injectScript('before-hydration', `import '@lit-labs/ssr-client/lit-element-hydrate-support.js';`);
89+
} else {
90+
// Inline <script> in <head> sets up the one-shot
91+
// globalThis.litElementHydrateSupport callback before any
92+
// module imports lit.
93+
injectScript(
94+
'head-inline',
95+
readFileSync(new URL('../hydration-support-global.js', import.meta.url), { encoding: 'utf-8' })
96+
);
97+
// The Vite plugin (litHydrationPlugin) prepends the
98+
// hydration-support.js import to any module that imports
99+
// from lit, ensuring they share one chunk.
100+
}
101+
42102
addRenderer({
43103
name: '@semantic-ui/astro-lit',
44104
serverEntrypoint: '@semantic-ui/astro-lit/server.js',
45105
clientEntrypoint: '@semantic-ui/astro-lit/dist/client.js',
46106
});
47-
// Update the vite configuration.
107+
48108
updateConfig({
49-
vite: getViteConfiguration(),
109+
vite: getViteConfiguration(!litNative),
50110
});
51111
},
52112
'astro:build:setup': ({ vite, target }) => {

0 commit comments

Comments
 (0)