[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:
parent
e193dfad1e
commit
0db2204153
13 changed files with 181 additions and 35 deletions
5
.changeset/dry-sloths-flash.md
Normal file
5
.changeset/dry-sloths-flash.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@astrojs/lit': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fixes Lit hydration not having the same reactive values as server (losing state upon hydration)
|
|
@ -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);
|
|
@ -1,8 +1,9 @@
|
||||||
---
|
---
|
||||||
import MyCounter from '../components/Counter.js';
|
import MyCounter from '../components/Counter.js';
|
||||||
|
import NonDeferredCounter from '../components/NonDeferredCounter.js';
|
||||||
|
|
||||||
const someProps = {
|
const someProps = {
|
||||||
count: 0,
|
count: 10,
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -15,6 +16,9 @@ const someProps = {
|
||||||
<h1>Hello, client:idle!</h1>
|
<h1>Hello, client:idle!</h1>
|
||||||
</MyCounter>
|
</MyCounter>
|
||||||
|
|
||||||
|
<NonDeferredCounter id="non-deferred" client:idle {...someProps}>
|
||||||
|
</NonDeferredCounter>
|
||||||
|
|
||||||
<MyCounter id="client-load" {...someProps} client:load>
|
<MyCounter id="client-load" {...someProps} client:load>
|
||||||
<h1>Hello, client:load!</h1>
|
<h1>Hello, client:load!</h1>
|
||||||
</MyCounter>
|
</MyCounter>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import MyCounter from '../components/Counter.js';
|
import MyCounter from '../components/Counter.js';
|
||||||
|
|
||||||
const someProps = {
|
const someProps = {
|
||||||
count: 0,
|
count: 10,
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import MyCounter from '../components/Counter.js';
|
import MyCounter from '../components/Counter.js';
|
||||||
|
|
||||||
const someProps = {
|
const someProps = {
|
||||||
count: 0,
|
count: 10,
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
@ -32,12 +32,25 @@ test.describe('Lit components', () => {
|
||||||
await expect(counter).toHaveCount(1);
|
await expect(counter).toHaveCount(1);
|
||||||
|
|
||||||
const count = counter.locator('p');
|
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');
|
const inc = counter.locator('button');
|
||||||
await inc.click();
|
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 }) => {
|
t('client:load', async ({ page, astro }) => {
|
||||||
|
@ -47,12 +60,12 @@ test.describe('Lit components', () => {
|
||||||
await expect(counter, 'component is visible').toBeVisible();
|
await expect(counter, 'component is visible').toBeVisible();
|
||||||
|
|
||||||
const count = counter.locator('p');
|
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');
|
const inc = counter.locator('button');
|
||||||
await inc.click();
|
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 }) => {
|
t('client:visible', async ({ page, astro }) => {
|
||||||
|
@ -64,12 +77,12 @@ test.describe('Lit components', () => {
|
||||||
await expect(counter, 'component is visible').toBeVisible();
|
await expect(counter, 'component is visible').toBeVisible();
|
||||||
|
|
||||||
const count = counter.locator('p');
|
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');
|
const inc = counter.locator('button');
|
||||||
await inc.click();
|
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 }) => {
|
t('client:media', async ({ page, astro }) => {
|
||||||
|
@ -79,18 +92,18 @@ test.describe('Lit components', () => {
|
||||||
await expect(counter, 'component is visible').toBeVisible();
|
await expect(counter, 'component is visible').toBeVisible();
|
||||||
|
|
||||||
const count = counter.locator('p');
|
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');
|
const inc = counter.locator('button');
|
||||||
await inc.click();
|
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)
|
// Reset the viewport to hydrate the component (max-width: 50rem)
|
||||||
await page.setViewportSize({ width: 414, height: 1124 });
|
await page.setViewportSize({ width: 414, height: 1124 });
|
||||||
|
|
||||||
await inc.click();
|
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 }) => {
|
t.skip('HMR', async ({ page, astro }) => {
|
||||||
|
|
23
packages/astro/test/fixtures/lit-element/src/components/non-deferred-element.ts
vendored
Normal file
23
packages/astro/test/fixtures/lit-element/src/components/non-deferred-element.ts
vendored
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
import {MyElement} from '../components/my-element.js';
|
import {MyElement} from '../components/my-element.js';
|
||||||
|
import {NonDeferredCounter} from '../components/non-deferred-element.js';
|
||||||
---
|
---
|
||||||
|
|
||||||
<html>
|
<html>
|
||||||
|
@ -8,11 +9,17 @@ import {MyElement} from '../components/my-element.js';
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<MyElement
|
<MyElement
|
||||||
|
id="default"
|
||||||
foo="bar"
|
foo="bar"
|
||||||
str-attr={'initialized'}
|
str-attr={'initialized'}
|
||||||
bool={false}
|
bool={false}
|
||||||
obj={{data: 1}}
|
obj={{data: 1}}
|
||||||
reflectedStrProp={'initialized reflected'}>
|
reflectedStrProp={'initialized reflected'}>
|
||||||
</MyElement>
|
</MyElement>
|
||||||
|
<NonDeferredCounter
|
||||||
|
id="non-deferred"
|
||||||
|
count={10}
|
||||||
|
foo="bar">
|
||||||
|
</NonDeferredCounter>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -30,36 +30,59 @@ describe('LitElement test', function () {
|
||||||
const $ = cheerio.load(html);
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
// test 1: attributes rendered – non reactive properties
|
// 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
|
// 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
|
// test 3: string reactive property set
|
||||||
expect(stripExpressionMarkers($('my-element').html())).to.include(
|
expect(stripExpressionMarkers($('#default').html())).to.include(
|
||||||
`<div id="str">initialized</div>`
|
`<div id="str">initialized</div>`
|
||||||
);
|
);
|
||||||
|
|
||||||
// test 4: boolean reactive property correctly set
|
// test 4: boolean reactive property correctly set
|
||||||
// <my-element bool="false"> Lit will equate to true because it uses
|
// <my-element bool="false"> Lit will equate to true because it uses
|
||||||
// this.hasAttribute to determine its value
|
// 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
|
// test 5: object reactive property set
|
||||||
// by default objects will be stringified to [object Object]
|
// 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>`
|
`<div id="data">data: 1</div>`
|
||||||
);
|
);
|
||||||
|
|
||||||
// test 6: reactive properties are not rendered as attributes
|
// test 6: reactive properties are not rendered as attributes
|
||||||
expect($('my-element').attr('obj')).to.equal(undefined);
|
expect($('#default').attr('obj')).to.equal(undefined);
|
||||||
expect($('my-element').attr('bool')).to.equal(undefined);
|
expect($('#default').attr('bool')).to.equal(undefined);
|
||||||
expect($('my-element').attr('str')).to.equal(undefined);
|
expect($('#default').attr('str')).to.equal(undefined);
|
||||||
|
|
||||||
// test 7: reflected reactive props are rendered as attributes
|
// test 7: reflected reactive props are rendered as attributes
|
||||||
expect($('my-element').attr('reflectedbool')).to.equal('');
|
expect($('#default').attr('reflectedbool')).to.equal('');
|
||||||
expect($('my-element').attr('reflected-str')).to.equal('default reflected string');
|
expect($('#default').attr('reflected-str')).to.equal('default reflected string');
|
||||||
expect($('my-element').attr('reflected-str-prop')).to.equal('initialized reflected');
|
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 () => {
|
it('Correctly passes child slots', async () => {
|
||||||
|
@ -74,7 +97,7 @@ describe('LitElement test', function () {
|
||||||
const $slottedMyElement = $('#slotted');
|
const $slottedMyElement = $('#slotted');
|
||||||
const $slottedSlottedMyElement = $('#slotted-slotted');
|
const $slottedSlottedMyElement = $('#slotted-slotted');
|
||||||
|
|
||||||
expect($('my-element').length).to.equal(3);
|
expect($('#default').length).to.equal(3);
|
||||||
|
|
||||||
// Root my-element
|
// Root my-element
|
||||||
expect($rootMyElement.children('.default').length).to.equal(2);
|
expect($rootMyElement.children('.default').length).to.equal(2);
|
||||||
|
|
|
@ -23,6 +23,7 @@
|
||||||
".": "./dist/index.js",
|
".": "./dist/index.js",
|
||||||
"./server.js": "./server.js",
|
"./server.js": "./server.js",
|
||||||
"./client-shim.js": "./client-shim.js",
|
"./client-shim.js": "./client-shim.js",
|
||||||
|
"./dist/client.js": "./dist/client.js",
|
||||||
"./hydration-support.js": "./hydration-support.js",
|
"./hydration-support.js": "./hydration-support.js",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
|
|
|
@ -36,10 +36,18 @@ function* render(Component, attrs, slots) {
|
||||||
|
|
||||||
// LitElementRenderer creates a new element instance, so copy over.
|
// LitElementRenderer creates a new element instance, so copy over.
|
||||||
const Ctr = getCustomElementConstructor(tagName);
|
const Ctr = getCustomElementConstructor(tagName);
|
||||||
|
let shouldDeferHydration = false;
|
||||||
|
|
||||||
if (attrs) {
|
if (attrs) {
|
||||||
for (let [name, value] of Object.entries(attrs)) {
|
for (let [name, value] of Object.entries(attrs)) {
|
||||||
// check if this is a reactive property
|
const isReactiveProperty = name in Ctr.prototype;
|
||||||
if (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);
|
instance.setProperty(name, value);
|
||||||
} else {
|
} else {
|
||||||
instance.setAttribute(name, value);
|
instance.setAttribute(name, value);
|
||||||
|
@ -49,7 +57,7 @@ function* render(Component, attrs, slots) {
|
||||||
|
|
||||||
instance.connectedCallback();
|
instance.connectedCallback();
|
||||||
|
|
||||||
yield `<${tagName}`;
|
yield `<${tagName}${shouldDeferHydration ? ' defer-hydration' : ''}`;
|
||||||
yield* instance.renderAttributes();
|
yield* instance.renderAttributes();
|
||||||
yield `>`;
|
yield `>`;
|
||||||
const shadowContents = instance.renderShadow({});
|
const shadowContents = instance.renderShadow({});
|
||||||
|
|
25
packages/integrations/lit/src/client.ts
Normal file
25
packages/integrations/lit/src/client.ts
Normal 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');
|
||||||
|
};
|
|
@ -5,6 +5,7 @@ function getViteConfiguration() {
|
||||||
return {
|
return {
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: [
|
include: [
|
||||||
|
'@astrojs/lit/dist/client.js',
|
||||||
'@astrojs/lit/client-shim.js',
|
'@astrojs/lit/client-shim.js',
|
||||||
'@astrojs/lit/hydration-support.js',
|
'@astrojs/lit/hydration-support.js',
|
||||||
'@webcomponents/template-shadowroot/template-shadowroot.js',
|
'@webcomponents/template-shadowroot/template-shadowroot.js',
|
||||||
|
@ -34,6 +35,7 @@ export default function (): AstroIntegration {
|
||||||
addRenderer({
|
addRenderer({
|
||||||
name: '@astrojs/lit',
|
name: '@astrojs/lit',
|
||||||
serverEntrypoint: '@astrojs/lit/server.js',
|
serverEntrypoint: '@astrojs/lit/server.js',
|
||||||
|
clientEntrypoint: '@astrojs/lit/dist/client.js',
|
||||||
});
|
});
|
||||||
// Update the vite configuration.
|
// Update the vite configuration.
|
||||||
updateConfig({
|
updateConfig({
|
||||||
|
|
Loading…
Reference in a new issue