[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
This commit is contained in:
Elliott Marquez 2023-02-01 05:18:37 -08:00 committed by Matthew Phillips
parent 6386abd30b
commit 262a24b5c3
13 changed files with 181 additions and 35 deletions

View file

@ -0,0 +1,5 @@
---
'@astrojs/lit': patch
---
Fixes Lit hydration not having the same reactive values as server (losing state upon hydration)

View file

@ -0,0 +1,35 @@
import { LitElement, html } from 'lit';
export default class NonDeferredCounter extends LitElement {
static get properties() {
return {
count: {
type: Number,
// All set properties are reflected to attributes so its hydration is
// not deferred.
reflect: true,
},
};
}
constructor() {
super();
this.count = 0;
}
increment() {
this.count++;
}
render() {
return html`
<div>
<p>Count: ${this.count}</p>
<button type="button" @click=${this.increment}>Increment</button>
</div>
`;
}
}
customElements.define('non-deferred-counter', NonDeferredCounter);

View file

@ -1,8 +1,9 @@
---
import MyCounter from '../components/Counter.js';
import NonDeferredCounter from '../components/NonDeferredCounter.js';
const someProps = {
count: 0,
count: 10,
};
---
@ -15,6 +16,9 @@ const someProps = {
<h1>Hello, client:idle!</h1>
</MyCounter>
<NonDeferredCounter id="non-deferred" client:idle {...someProps}>
</NonDeferredCounter>
<MyCounter id="client-load" {...someProps} client:load>
<h1>Hello, client:load!</h1>
</MyCounter>

View file

@ -2,7 +2,7 @@
import MyCounter from '../components/Counter.js';
const someProps = {
count: 0,
count: 10,
};
---

View file

@ -2,7 +2,7 @@
import MyCounter from '../components/Counter.js';
const someProps = {
count: 0,
count: 10,
};
---

View file

@ -32,12 +32,25 @@ test.describe('Lit components', () => {
await expect(counter).toHaveCount(1);
const count = counter.locator('p');
await expect(count, 'initial count is 0').toHaveText('Count: 0');
await expect(count, 'initial count is 10').toHaveText('Count: 10');
const inc = counter.locator('button');
await inc.click();
await expect(count, 'count incremented by 1').toHaveText('Count: 1');
await expect(count, 'count incremented by 1').toHaveText('Count: 11');
});
t('non-deferred attribute serialization', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
const counter = page.locator('#non-deferred');
const count = counter.locator('p');
await expect(count, 'initial count is 10').toHaveText('Count: 10');
const inc = counter.locator('button');
await inc.click();
await expect(count, 'count incremented by 1').toHaveText('Count: 11');
});
t('client:load', async ({ page, astro }) => {
@ -47,12 +60,12 @@ test.describe('Lit components', () => {
await expect(counter, 'component is visible').toBeVisible();
const count = counter.locator('p');
await expect(count, 'initial count is 0').toHaveText('Count: 0');
await expect(count, 'initial count is 10').toHaveText('Count: 10');
const inc = counter.locator('button');
await inc.click();
await expect(count, 'count incremented by 1').toHaveText('Count: 1');
await expect(count, 'count incremented by 1').toHaveText('Count: 11');
});
t('client:visible', async ({ page, astro }) => {
@ -64,12 +77,12 @@ test.describe('Lit components', () => {
await expect(counter, 'component is visible').toBeVisible();
const count = counter.locator('p');
await expect(count, 'initial count is 0').toHaveText('Count: 0');
await expect(count, 'initial count is 10').toHaveText('Count: 10');
const inc = counter.locator('button');
await inc.click();
await expect(count, 'count incremented by 1').toHaveText('Count: 1');
await expect(count, 'count incremented by 1').toHaveText('Count: 11');
});
t('client:media', async ({ page, astro }) => {
@ -79,18 +92,18 @@ test.describe('Lit components', () => {
await expect(counter, 'component is visible').toBeVisible();
const count = counter.locator('p');
await expect(count, 'initial count is 0').toHaveText('Count: 0');
await expect(count, 'initial count is 10').toHaveText('Count: 10');
const inc = counter.locator('button');
await inc.click();
await expect(count, 'component not hydrated yet').toHaveText('Count: 0');
await expect(count, 'component not hydrated yet').toHaveText('Count: 10');
// Reset the viewport to hydrate the component (max-width: 50rem)
await page.setViewportSize({ width: 414, height: 1124 });
await inc.click();
await expect(count, 'count incremented by 1').toHaveText('Count: 1');
await expect(count, 'count incremented by 1').toHaveText('Count: 11');
});
t.skip('HMR', async ({ page, astro }) => {

View file

@ -0,0 +1,23 @@
import { LitElement, html } from 'lit';
import { property, customElement } from 'lit/decorators.js';
@customElement('non-deferred-counter')
export class NonDeferredCounter extends LitElement {
// All set properties are reflected to attributes so its hydration is not
// hydration-deferred should always be set.
@property({ type: Number, reflect: true }) count = 0;
increment() {
this.count++;
}
render() {
return html`
<div>
<p>Count: ${this.count}</p>
<button type="button" @click=${this.increment}>Increment</button>
</div>
`;
}
}

View file

@ -1,18 +1,25 @@
---
import {MyElement} from '../components/my-element.js';
import {NonDeferredCounter} from '../components/non-deferred-element.js';
---
<html>
<head>
<title>LitElements</title>
<title>LitElements</title>
</head>
<body>
<MyElement
foo="bar"
str-attr={'initialized'}
bool={false}
obj={{data: 1}}
reflectedStrProp={'initialized reflected'}>
</MyElement>
<MyElement
id="default"
foo="bar"
str-attr={'initialized'}
bool={false}
obj={{data: 1}}
reflectedStrProp={'initialized reflected'}>
</MyElement>
<NonDeferredCounter
id="non-deferred"
count={10}
foo="bar">
</NonDeferredCounter>
</body>
</html>

View file

@ -30,36 +30,59 @@ describe('LitElement test', function () {
const $ = cheerio.load(html);
// test 1: attributes rendered non reactive properties
expect($('my-element').attr('foo')).to.equal('bar');
expect($('#default').attr('foo')).to.equal('bar');
// test 2: shadow rendered
expect($('my-element').html()).to.include(`<div>Testing...</div>`);
expect($('#default').html()).to.include(`<div>Testing...</div>`);
// test 3: string reactive property set
expect(stripExpressionMarkers($('my-element').html())).to.include(
expect(stripExpressionMarkers($('#default').html())).to.include(
`<div id="str">initialized</div>`
);
// test 4: boolean reactive property correctly set
// <my-element bool="false"> Lit will equate to true because it uses
// this.hasAttribute to determine its value
expect(stripExpressionMarkers($('my-element').html())).to.include(`<div id="bool">B</div>`);
expect(stripExpressionMarkers($('#default').html())).to.include(`<div id="bool">B</div>`);
// test 5: object reactive property set
// by default objects will be stringified to [object Object]
expect(stripExpressionMarkers($('my-element').html())).to.include(
expect(stripExpressionMarkers($('#default').html())).to.include(
`<div id="data">data: 1</div>`
);
// test 6: reactive properties are not rendered as attributes
expect($('my-element').attr('obj')).to.equal(undefined);
expect($('my-element').attr('bool')).to.equal(undefined);
expect($('my-element').attr('str')).to.equal(undefined);
expect($('#default').attr('obj')).to.equal(undefined);
expect($('#default').attr('bool')).to.equal(undefined);
expect($('#default').attr('str')).to.equal(undefined);
// test 7: reflected reactive props are rendered as attributes
expect($('my-element').attr('reflectedbool')).to.equal('');
expect($('my-element').attr('reflected-str')).to.equal('default reflected string');
expect($('my-element').attr('reflected-str-prop')).to.equal('initialized reflected');
expect($('#default').attr('reflectedbool')).to.equal('');
expect($('#default').attr('reflected-str')).to.equal('default reflected string');
expect($('#default').attr('reflected-str-prop')).to.equal('initialized reflected');
});
it('Sets defer-hydration on element only when necessary', async () => {
// @lit-labs/ssr/ requires Node 13.9 or higher
if (NODE_VERSION < 13.9) {
return;
}
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
// test 1: reflected reactive props are rendered as attributes
expect($('#non-deferred').attr('count')).to.equal('10');
// test 2: non-reactive props are set as attributes
expect($('#non-deferred').attr('foo')).to.equal('bar');
// test 3: components with only reflected reactive props set are not
// deferred because their state can be completely serialized via attributes
expect($('#non-deferred').attr('defer-hydration')).to.equal(undefined);
// test 4: components with non-reflected reactive props set are deferred because
// their state needs to be synced with the server on the client.
expect($('#default').attr('defer-hydration')).to.equal('');
});
it('Correctly passes child slots', async () => {
@ -74,7 +97,7 @@ describe('LitElement test', function () {
const $slottedMyElement = $('#slotted');
const $slottedSlottedMyElement = $('#slotted-slotted');
expect($('my-element').length).to.equal(3);
expect($('#default').length).to.equal(3);
// Root my-element
expect($rootMyElement.children('.default').length).to.equal(2);

View file

@ -23,6 +23,7 @@
".": "./dist/index.js",
"./server.js": "./server.js",
"./client-shim.js": "./client-shim.js",
"./dist/client.js": "./dist/client.js",
"./hydration-support.js": "./hydration-support.js",
"./package.json": "./package.json"
},

View file

@ -36,10 +36,18 @@ function* render(Component, attrs, slots) {
// LitElementRenderer creates a new element instance, so copy over.
const Ctr = getCustomElementConstructor(tagName);
let shouldDeferHydration = false;
if (attrs) {
for (let [name, value] of Object.entries(attrs)) {
// check if this is a reactive property
if (name in Ctr.prototype) {
const isReactiveProperty = name in Ctr.prototype;
const isReflectedReactiveProperty = Ctr.elementProperties.get(name)?.reflect;
// Only defer hydration if we are setting a reactive property that cannot
// be reflected / serialized as a property.
shouldDeferHydration ||= isReactiveProperty && !isReflectedReactiveProperty;
if (isReactiveProperty) {
instance.setProperty(name, value);
} else {
instance.setAttribute(name, value);
@ -49,7 +57,7 @@ function* render(Component, attrs, slots) {
instance.connectedCallback();
yield `<${tagName}`;
yield `<${tagName}${shouldDeferHydration ? ' defer-hydration' : ''}`;
yield* instance.renderAttributes();
yield `>`;
const shadowContents = instance.renderShadow({});

View file

@ -0,0 +1,25 @@
export default (element: HTMLElement) =>
async (
Component: any,
props: Record<string, any>,
) => {
// Get the LitElement element instance (may or may not be upgraded).
const component = element.children[0] as HTMLElement;
// If there is no deferral of hydration, then all reactive properties are
// already serialzied as reflected attributes, or no reactive props were set
if (!component || !component.hasAttribute('defer-hydration')) {
return;
}
// Set properties on the LitElement instance for resuming hydration.
for (let [name, value] of Object.entries(props)) {
// Check if reactive property or class property.
if (name in Component.prototype) {
(component as any)[name] = value;
}
}
// Tell LitElement to resume hydration.
component.removeAttribute('defer-hydration');
};

View file

@ -5,6 +5,7 @@ function getViteConfiguration() {
return {
optimizeDeps: {
include: [
'@astrojs/lit/dist/client.js',
'@astrojs/lit/client-shim.js',
'@astrojs/lit/hydration-support.js',
'@webcomponents/template-shadowroot/template-shadowroot.js',
@ -34,6 +35,7 @@ export default function (): AstroIntegration {
addRenderer({
name: '@astrojs/lit',
serverEntrypoint: '@astrojs/lit/server.js',
clientEntrypoint: '@astrojs/lit/dist/client.js',
});
// Update the vite configuration.
updateConfig({