Skip to content

Commit 0db2204

Browse files
authored
[Lit] Fix hydration not having the same reactive values as server (#6080)
* Fix lit hydration not having the same reactive values * add changeset * add clientEntrypoint to package exports * update tests * add changeset * only add defer-hydration when strictly necessary * remove second changest * fix test typos
1 parent e193dfa commit 0db2204

File tree

13 files changed

+181
-35
lines changed

13 files changed

+181
-35
lines changed

.changeset/dry-sloths-flash.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/lit': patch
3+
---
4+
5+
Fixes Lit hydration not having the same reactive values as server (losing state upon hydration)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { LitElement, html } from 'lit';
2+
3+
export default class NonDeferredCounter extends LitElement {
4+
static get properties() {
5+
return {
6+
count: {
7+
type: Number,
8+
// All set properties are reflected to attributes so its hydration is
9+
// not deferred.
10+
reflect: true,
11+
},
12+
};
13+
}
14+
15+
constructor() {
16+
super();
17+
this.count = 0;
18+
}
19+
20+
increment() {
21+
this.count++;
22+
}
23+
24+
render() {
25+
return html`
26+
<div>
27+
<p>Count: ${this.count}</p>
28+
29+
<button type="button" @click=${this.increment}>Increment</button>
30+
</div>
31+
`;
32+
}
33+
}
34+
35+
customElements.define('non-deferred-counter', NonDeferredCounter);

packages/astro/e2e/fixtures/lit-component/src/pages/index.astro

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
---
22
import MyCounter from '../components/Counter.js';
3+
import NonDeferredCounter from '../components/NonDeferredCounter.js';
34
45
const someProps = {
5-
count: 0,
6+
count: 10,
67
};
78
---
89

@@ -15,6 +16,9 @@ const someProps = {
1516
<h1>Hello, client:idle!</h1>
1617
</MyCounter>
1718

19+
<NonDeferredCounter id="non-deferred" client:idle {...someProps}>
20+
</NonDeferredCounter>
21+
1822
<MyCounter id="client-load" {...someProps} client:load>
1923
<h1>Hello, client:load!</h1>
2024
</MyCounter>

packages/astro/e2e/fixtures/lit-component/src/pages/media.astro

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import MyCounter from '../components/Counter.js';
33
44
const someProps = {
5-
count: 0,
5+
count: 10,
66
};
77
---
88

packages/astro/e2e/fixtures/lit-component/src/pages/solo.astro

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import MyCounter from '../components/Counter.js';
33
44
const someProps = {
5-
count: 0,
5+
count: 10,
66
};
77
---
88

packages/astro/e2e/lit-component.test.js

+22-9
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,25 @@ test.describe('Lit components', () => {
3232
await expect(counter).toHaveCount(1);
3333

3434
const count = counter.locator('p');
35-
await expect(count, 'initial count is 0').toHaveText('Count: 0');
35+
await expect(count, 'initial count is 10').toHaveText('Count: 10');
3636

3737
const inc = counter.locator('button');
3838
await inc.click();
3939

40-
await expect(count, 'count incremented by 1').toHaveText('Count: 1');
40+
await expect(count, 'count incremented by 1').toHaveText('Count: 11');
41+
});
42+
43+
t('non-deferred attribute serialization', async ({ page, astro }) => {
44+
await page.goto(astro.resolveUrl('/'));
45+
46+
const counter = page.locator('#non-deferred');
47+
const count = counter.locator('p');
48+
await expect(count, 'initial count is 10').toHaveText('Count: 10');
49+
50+
const inc = counter.locator('button');
51+
await inc.click();
52+
53+
await expect(count, 'count incremented by 1').toHaveText('Count: 11');
4154
});
4255

4356
t('client:load', async ({ page, astro }) => {
@@ -47,12 +60,12 @@ test.describe('Lit components', () => {
4760
await expect(counter, 'component is visible').toBeVisible();
4861

4962
const count = counter.locator('p');
50-
await expect(count, 'initial count is 0').toHaveText('Count: 0');
63+
await expect(count, 'initial count is 10').toHaveText('Count: 10');
5164

5265
const inc = counter.locator('button');
5366
await inc.click();
5467

55-
await expect(count, 'count incremented by 1').toHaveText('Count: 1');
68+
await expect(count, 'count incremented by 1').toHaveText('Count: 11');
5669
});
5770

5871
t('client:visible', async ({ page, astro }) => {
@@ -64,12 +77,12 @@ test.describe('Lit components', () => {
6477
await expect(counter, 'component is visible').toBeVisible();
6578

6679
const count = counter.locator('p');
67-
await expect(count, 'initial count is 0').toHaveText('Count: 0');
80+
await expect(count, 'initial count is 10').toHaveText('Count: 10');
6881

6982
const inc = counter.locator('button');
7083
await inc.click();
7184

72-
await expect(count, 'count incremented by 1').toHaveText('Count: 1');
85+
await expect(count, 'count incremented by 1').toHaveText('Count: 11');
7386
});
7487

7588
t('client:media', async ({ page, astro }) => {
@@ -79,18 +92,18 @@ test.describe('Lit components', () => {
7992
await expect(counter, 'component is visible').toBeVisible();
8093

8194
const count = counter.locator('p');
82-
await expect(count, 'initial count is 0').toHaveText('Count: 0');
95+
await expect(count, 'initial count is 10').toHaveText('Count: 10');
8396

8497
const inc = counter.locator('button');
8598
await inc.click();
8699

87-
await expect(count, 'component not hydrated yet').toHaveText('Count: 0');
100+
await expect(count, 'component not hydrated yet').toHaveText('Count: 10');
88101

89102
// Reset the viewport to hydrate the component (max-width: 50rem)
90103
await page.setViewportSize({ width: 414, height: 1124 });
91104

92105
await inc.click();
93-
await expect(count, 'count incremented by 1').toHaveText('Count: 1');
106+
await expect(count, 'count incremented by 1').toHaveText('Count: 11');
94107
});
95108

96109
t.skip('HMR', async ({ page, astro }) => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { LitElement, html } from 'lit';
2+
import { property, customElement } from 'lit/decorators.js';
3+
4+
@customElement('non-deferred-counter')
5+
export class NonDeferredCounter extends LitElement {
6+
// All set properties are reflected to attributes so its hydration is not
7+
// hydration-deferred should always be set.
8+
@property({ type: Number, reflect: true }) count = 0;
9+
10+
increment() {
11+
this.count++;
12+
}
13+
14+
render() {
15+
return html`
16+
<div>
17+
<p>Count: ${this.count}</p>
18+
19+
<button type="button" @click=${this.increment}>Increment</button>
20+
</div>
21+
`;
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
---
22
import {MyElement} from '../components/my-element.js';
3+
import {NonDeferredCounter} from '../components/non-deferred-element.js';
34
---
45

56
<html>
67
<head>
7-
<title>LitElements</title>
8+
<title>LitElements</title>
89
</head>
910
<body>
10-
<MyElement
11-
foo="bar"
12-
str-attr={'initialized'}
13-
bool={false}
14-
obj={{data: 1}}
15-
reflectedStrProp={'initialized reflected'}>
16-
</MyElement>
11+
<MyElement
12+
id="default"
13+
foo="bar"
14+
str-attr={'initialized'}
15+
bool={false}
16+
obj={{data: 1}}
17+
reflectedStrProp={'initialized reflected'}>
18+
</MyElement>
19+
<NonDeferredCounter
20+
id="non-deferred"
21+
count={10}
22+
foo="bar">
23+
</NonDeferredCounter>
1724
</body>
1825
</html>

packages/astro/test/lit-element.test.js

+35-12
Original file line numberDiff line numberDiff line change
@@ -30,36 +30,59 @@ describe('LitElement test', function () {
3030
const $ = cheerio.load(html);
3131

3232
// test 1: attributes rendered – non reactive properties
33-
expect($('my-element').attr('foo')).to.equal('bar');
33+
expect($('#default').attr('foo')).to.equal('bar');
3434

3535
// test 2: shadow rendered
36-
expect($('my-element').html()).to.include(`<div>Testing...</div>`);
36+
expect($('#default').html()).to.include(`<div>Testing...</div>`);
3737

3838
// test 3: string reactive property set
39-
expect(stripExpressionMarkers($('my-element').html())).to.include(
39+
expect(stripExpressionMarkers($('#default').html())).to.include(
4040
`<div id="str">initialized</div>`
4141
);
4242

4343
// test 4: boolean reactive property correctly set
4444
// <my-element bool="false"> Lit will equate to true because it uses
4545
// this.hasAttribute to determine its value
46-
expect(stripExpressionMarkers($('my-element').html())).to.include(`<div id="bool">B</div>`);
46+
expect(stripExpressionMarkers($('#default').html())).to.include(`<div id="bool">B</div>`);
4747

4848
// test 5: object reactive property set
4949
// by default objects will be stringified to [object Object]
50-
expect(stripExpressionMarkers($('my-element').html())).to.include(
50+
expect(stripExpressionMarkers($('#default').html())).to.include(
5151
`<div id="data">data: 1</div>`
5252
);
5353

5454
// test 6: reactive properties are not rendered as attributes
55-
expect($('my-element').attr('obj')).to.equal(undefined);
56-
expect($('my-element').attr('bool')).to.equal(undefined);
57-
expect($('my-element').attr('str')).to.equal(undefined);
55+
expect($('#default').attr('obj')).to.equal(undefined);
56+
expect($('#default').attr('bool')).to.equal(undefined);
57+
expect($('#default').attr('str')).to.equal(undefined);
5858

5959
// test 7: reflected reactive props are rendered as attributes
60-
expect($('my-element').attr('reflectedbool')).to.equal('');
61-
expect($('my-element').attr('reflected-str')).to.equal('default reflected string');
62-
expect($('my-element').attr('reflected-str-prop')).to.equal('initialized reflected');
60+
expect($('#default').attr('reflectedbool')).to.equal('');
61+
expect($('#default').attr('reflected-str')).to.equal('default reflected string');
62+
expect($('#default').attr('reflected-str-prop')).to.equal('initialized reflected');
63+
});
64+
65+
it('Sets defer-hydration on element only when necessary', async () => {
66+
// @lit-labs/ssr/ requires Node 13.9 or higher
67+
if (NODE_VERSION < 13.9) {
68+
return;
69+
}
70+
const html = await fixture.readFile('/index.html');
71+
const $ = cheerio.load(html);
72+
73+
// test 1: reflected reactive props are rendered as attributes
74+
expect($('#non-deferred').attr('count')).to.equal('10');
75+
76+
// test 2: non-reactive props are set as attributes
77+
expect($('#non-deferred').attr('foo')).to.equal('bar');
78+
79+
// test 3: components with only reflected reactive props set are not
80+
// deferred because their state can be completely serialized via attributes
81+
expect($('#non-deferred').attr('defer-hydration')).to.equal(undefined);
82+
83+
// test 4: components with non-reflected reactive props set are deferred because
84+
// their state needs to be synced with the server on the client.
85+
expect($('#default').attr('defer-hydration')).to.equal('');
6386
});
6487

6588
it('Correctly passes child slots', async () => {
@@ -74,7 +97,7 @@ describe('LitElement test', function () {
7497
const $slottedMyElement = $('#slotted');
7598
const $slottedSlottedMyElement = $('#slotted-slotted');
7699

77-
expect($('my-element').length).to.equal(3);
100+
expect($('#default').length).to.equal(3);
78101

79102
// Root my-element
80103
expect($rootMyElement.children('.default').length).to.equal(2);

packages/integrations/lit/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
".": "./dist/index.js",
2424
"./server.js": "./server.js",
2525
"./client-shim.js": "./client-shim.js",
26+
"./dist/client.js": "./dist/client.js",
2627
"./hydration-support.js": "./hydration-support.js",
2728
"./package.json": "./package.json"
2829
},

packages/integrations/lit/server.js

+11-3
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,18 @@ function* render(Component, attrs, slots) {
3636

3737
// LitElementRenderer creates a new element instance, so copy over.
3838
const Ctr = getCustomElementConstructor(tagName);
39+
let shouldDeferHydration = false;
40+
3941
if (attrs) {
4042
for (let [name, value] of Object.entries(attrs)) {
41-
// check if this is a reactive property
42-
if (name in Ctr.prototype) {
43+
const isReactiveProperty = name in Ctr.prototype;
44+
const isReflectedReactiveProperty = Ctr.elementProperties.get(name)?.reflect;
45+
46+
// Only defer hydration if we are setting a reactive property that cannot
47+
// be reflected / serialized as a property.
48+
shouldDeferHydration ||= isReactiveProperty && !isReflectedReactiveProperty;
49+
50+
if (isReactiveProperty) {
4351
instance.setProperty(name, value);
4452
} else {
4553
instance.setAttribute(name, value);
@@ -49,7 +57,7 @@ function* render(Component, attrs, slots) {
4957

5058
instance.connectedCallback();
5159

52-
yield `<${tagName}`;
60+
yield `<${tagName}${shouldDeferHydration ? ' defer-hydration' : ''}`;
5361
yield* instance.renderAttributes();
5462
yield `>`;
5563
const shadowContents = instance.renderShadow({});
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export default (element: HTMLElement) =>
2+
async (
3+
Component: any,
4+
props: Record<string, any>,
5+
) => {
6+
// Get the LitElement element instance (may or may not be upgraded).
7+
const component = element.children[0] as HTMLElement;
8+
9+
// If there is no deferral of hydration, then all reactive properties are
10+
// already serialzied as reflected attributes, or no reactive props were set
11+
if (!component || !component.hasAttribute('defer-hydration')) {
12+
return;
13+
}
14+
15+
// Set properties on the LitElement instance for resuming hydration.
16+
for (let [name, value] of Object.entries(props)) {
17+
// Check if reactive property or class property.
18+
if (name in Component.prototype) {
19+
(component as any)[name] = value;
20+
}
21+
}
22+
23+
// Tell LitElement to resume hydration.
24+
component.removeAttribute('defer-hydration');
25+
};

packages/integrations/lit/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ function getViteConfiguration() {
55
return {
66
optimizeDeps: {
77
include: [
8+
'@astrojs/lit/dist/client.js',
89
'@astrojs/lit/client-shim.js',
910
'@astrojs/lit/hydration-support.js',
1011
'@webcomponents/template-shadowroot/template-shadowroot.js',
@@ -34,6 +35,7 @@ export default function (): AstroIntegration {
3435
addRenderer({
3536
name: '@astrojs/lit',
3637
serverEntrypoint: '@astrojs/lit/server.js',
38+
clientEntrypoint: '@astrojs/lit/dist/client.js',
3739
});
3840
// Update the vite configuration.
3941
updateConfig({

0 commit comments

Comments
 (0)