diff --git a/.changeset/good-wolves-remain.md b/.changeset/good-wolves-remain.md new file mode 100644 index 000000000000..05274b21361f --- /dev/null +++ b/.changeset/good-wolves-remain.md @@ -0,0 +1,6 @@ +--- +'@astrojs/lit': minor +'astro': patch +--- + +Implement client:only functionality in Lit and add lit to the client:only warning diff --git a/packages/astro/e2e/fixtures/lit-component/src/components/ClientOnlyComponent.js b/packages/astro/e2e/fixtures/lit-component/src/components/ClientOnlyComponent.js new file mode 100644 index 000000000000..0fd16b50af01 --- /dev/null +++ b/packages/astro/e2e/fixtures/lit-component/src/components/ClientOnlyComponent.js @@ -0,0 +1,9 @@ +import { LitElement, html } from 'lit'; + +export default class ClientOnlyComponent extends LitElement { + render() { + return html`
Shadow dom default content should not be visible
`; + } +} + +customElements.define('client-only-component', ClientOnlyComponent); diff --git a/packages/astro/e2e/fixtures/lit-component/src/pages/index.astro b/packages/astro/e2e/fixtures/lit-component/src/pages/index.astro index f15e832dcbbd..43eb17a4ecb9 100644 --- a/packages/astro/e2e/fixtures/lit-component/src/pages/index.astro +++ b/packages/astro/e2e/fixtures/lit-component/src/pages/index.astro @@ -1,6 +1,7 @@ --- import MyCounter from '../components/Counter.js'; import NonDeferredCounter from '../components/NonDeferredCounter.js'; +import ClientOnlyComponent from '../components/ClientOnlyComponent.js'; const someProps = { count: 10, @@ -26,5 +27,12 @@ const someProps = {

Hello, client:visible!

+ + + Framework + client:only + component + Should not be visible + diff --git a/packages/astro/e2e/lit-component.test.js b/packages/astro/e2e/lit-component.test.js index 85af631b9441..8303c705803b 100644 --- a/packages/astro/e2e/lit-component.test.js +++ b/packages/astro/e2e/lit-component.test.js @@ -106,6 +106,27 @@ test.describe('Lit components', () => { await expect(count, 'count incremented by 1').toHaveText('Count: 11'); }); + test('client:only', async ({ page, astro }) => { + await page.goto(astro.resolveUrl('/')); + + const label = page.locator('#client-only'); + await expect(label, 'component is visible').toBeVisible(); + + // Light DOM reconstructed correctly (slots are rendered alphabetically) and shadow dom content rendered + await expect(label, 'slotted text is in DOM').toHaveText('Framework client:only component Should not be visible Shadow dom default content should not be visible'); + + // Projected content should be visible + await expect(page.locator('#client-only .default'), 'slotted element is visible').toBeVisible(); + await expect(page.locator('#client-only .foo1'), 'slotted element is visible').toBeVisible(); + await expect(page.locator('#client-only .foo2'), 'slotted element is visible').toBeVisible(); + + // Non-projected content should not be visible + await expect(page.locator('#client-only [slot="quux"]'), 'element without slot is not visible').toBeHidden(); + + // Default slot content should not be visible + await expect(page.locator('#client-only .defaultContent'), 'element without slot is not visible').toBeHidden(); + }); + t.skip('HMR', async ({ page, astro }) => { await page.goto(astro.resolveUrl('/')); diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts index 034e796677a2..432f45c75dca 100644 --- a/packages/astro/src/runtime/server/render/component.ts +++ b/packages/astro/src/runtime/server/render/component.ts @@ -39,6 +39,7 @@ function guessRenderers(componentUrl?: string): string[] { '@astrojs/solid-js', '@astrojs/vue', '@astrojs/svelte', + '@astrojs/lit', ]; } } diff --git a/packages/integrations/lit/src/client.ts b/packages/integrations/lit/src/client.ts index fb92ac3df744..b631471822f7 100644 --- a/packages/integrations/lit/src/client.ts +++ b/packages/integrations/lit/src/client.ts @@ -1,21 +1,72 @@ -export default (element: HTMLElement) => async (Component: any, props: Record) => { - // 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; +/** + * Adds the appropriate slot attribute to each top-level node in the given HTML + * string. + * + * @example + * addSlotAttrsToHtmlString('foo', '
bar
baz
'); + * // '
bar
baz
' + * + * @param slotName Name of slot to apply to HTML string. + * @param html Stringified HTML that should be projected into the given slotname. + * @returns A stringified HTML string with the slot attribute applied to each top-level node. + */ +const addSlotAttrsToHtmlString = (slotName: string, html: string) => { + const templ = document.createElement('template'); + templ.innerHTML = html; + Array.from(templ.content.children).forEach((node) => { + node.setAttribute('slot', slotName); + }); + return templ.innerHTML; +}; + +export default (element: HTMLElement) => + async ( + Component: any, + props: Record, + { default: defaultChildren, ...slotted }: { default: string; [slotName: string]: string } + ) => { + // Get the LitElement element instance. + let component = element.children[0]; + // Check if hydration model is client:only + const isClientOnly = element.getAttribute('client') === 'only'; + + // We need to attach the element and it's children to the DOM since it's not + // SSR'd. + if (isClientOnly) { + component = new Component(); + + const otherSlottedChildren = Object.entries(slotted) + .map(([slotName, htmlStr]) => addSlotAttrsToHtmlString(slotName, htmlStr)) + .join(''); + + // defaultChildren can actually be undefined, but TS will complain if we + // type it as so, make sure we don't render undefined. + component.innerHTML = `${defaultChildren ?? ''}${otherSlottedChildren}`; + element.appendChild(component); + + // Set props bound to non-reactive properties as attributes. + for (let [name, value] of Object.entries(props)) { + if (!(name in Component.prototype)) { + component.setAttribute(name, value); + } + } } - } - // Tell LitElement to resume hydration. - component.removeAttribute('defer-hydration'); -}; + // If there is no deferral of hydration, then all reactive properties are + // already serialzied as reflected attributes, or no reactive props were set + // Alternatively, if hydration is client:only proceed to set props. + if (!component || !(component.hasAttribute('defer-hydration') || isClientOnly)) { + 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'); + };