Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Lit] add client:only functionality to Lit integration #6111

Merged
merged 3 commits into from
Feb 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/good-wolves-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astrojs/lit': minor
'astro': patch
---

Implement client:only functionality in Lit and add lit to the client:only warning
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LitElement, html } from 'lit';

export default class ClientOnlyComponent extends LitElement {
render() {
return html`<slot><div class="defaultContent"> Shadow dom default content should not be visible</div></slot><slot name="foo"></slot><slot name="bar"></slot></div>`;
}
}

customElements.define('client-only-component', ClientOnlyComponent);
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -26,5 +27,12 @@ const someProps = {
<MyCounter id="client-visible" {...someProps} client:visible>
<h1>Hello, client:visible!</h1>
</MyCounter>

<ClientOnlyComponent id="client-only" client:only="lit">
Frame<span class="default">work </span>
<span slot="foo" class="foo1">client:only</span>
<span slot="foo" class="foo2"> component</span>
<span slot="quux"> Should not be visible</span>
</ClientOnlyComponent>
</body>
</html>
21 changes: 21 additions & 0 deletions packages/astro/e2e/lit-component.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('/'));

Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/runtime/server/render/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function guessRenderers(componentUrl?: string): string[] {
'@astrojs/solid-js',
'@astrojs/vue',
'@astrojs/svelte',
'@astrojs/lit',
];
}
}
Expand Down
89 changes: 70 additions & 19 deletions packages/integrations/lit/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,72 @@
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;
/**
* Adds the appropriate slot attribute to each top-level node in the given HTML
* string.
*
* @example
* addSlotAttrsToHtmlString('foo', '<div>bar</div><div>baz</div>');
* // '<div slot="foo">bar</div><div slot="foo">baz</div>'
*
* @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<string, any>,
{ 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');
};