diff --git a/packages/astro/src/runtime/server/astro-island.ts b/packages/astro/src/runtime/server/astro-island.ts index 79f41f558292..22d9dd00a50b 100644 --- a/packages/astro/src/runtime/server/astro-island.ts +++ b/packages/astro/src/runtime/server/astro-island.ts @@ -44,170 +44,174 @@ declare const Astro: { return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, reviveTuple(value)])); }; - if (!customElements.get('astro-island')) { - customElements.define( - 'astro-island', - class extends HTMLElement { - public Component: any; - public hydrator: any; - static observedAttributes = ['props']; - disconnectedCallback() { - document.removeEventListener('astro:after-swap', this.unmount); - document.addEventListener('astro:after-swap', this.unmount, { once: true }); - } - connectedCallback() { + // 🌊🏝️🌴 + class AstroIsland extends HTMLElement { + public Component: any; + public hydrator: any; + static observedAttributes = ['props']; + + disconnectedCallback() { + document.removeEventListener('astro:after-swap', this.unmount); + document.addEventListener('astro:after-swap', this.unmount, { once: true }); + } + + connectedCallback() { + if ( + !this.hasAttribute('await-children') || + document.readyState === 'interactive' || + document.readyState === 'complete' + ) { + this.childrenConnectedCallback(); + } else { + // connectedCallback may run *before* children are rendered (ex. HTML streaming) + // If SSR children are expected, but not yet rendered, wait with a mutation observer + // for a special marker inserted when rendering islands that signals the end of the island + const onConnected = () => { + document.removeEventListener('DOMContentLoaded', onConnected); + mo.disconnect(); + this.childrenConnectedCallback(); + }; + const mo = new MutationObserver(() => { if ( - !this.hasAttribute('await-children') || - document.readyState === 'interactive' || - document.readyState === 'complete' + this.lastChild?.nodeType === Node.COMMENT_NODE && + this.lastChild.nodeValue === 'astro:end' ) { - this.childrenConnectedCallback(); - } else { - // connectedCallback may run *before* children are rendered (ex. HTML streaming) - // If SSR children are expected, but not yet rendered, wait with a mutation observer - // for a special marker inserted when rendering islands that signals the end of the island - const onConnected = () => { - document.removeEventListener('DOMContentLoaded', onConnected); - mo.disconnect(); - this.childrenConnectedCallback(); - }; - const mo = new MutationObserver(() => { - if ( - this.lastChild?.nodeType === Node.COMMENT_NODE && - this.lastChild.nodeValue === 'astro:end' - ) { - this.lastChild.remove(); - onConnected(); - } - }); - mo.observe(this, { childList: true }); - // in case the marker comment got stripped and the mutation observer waited indefinitely, - // also wait for DOMContentLoaded as a last resort - document.addEventListener('DOMContentLoaded', onConnected); - } - } - async childrenConnectedCallback() { - let beforeHydrationUrl = this.getAttribute('before-hydration-url'); - if (beforeHydrationUrl) { - await import(beforeHydrationUrl); - } - this.start(); - } - async start() { - const opts = JSON.parse(this.getAttribute('opts')!) as Record; - const directive = this.getAttribute('client') as directiveAstroKeys; - if (Astro[directive] === undefined) { - window.addEventListener(`astro:${directive}`, () => this.start(), { once: true }); - return; - } - try { - await Astro[directive]!( - async () => { - const rendererUrl = this.getAttribute('renderer-url'); - const [componentModule, { default: hydrator }] = await Promise.all([ - import(this.getAttribute('component-url')!), - rendererUrl ? import(rendererUrl) : () => () => {}, - ]); - const componentExport = this.getAttribute('component-export') || 'default'; - if (!componentExport.includes('.')) { - this.Component = componentModule[componentExport]; - } else { - this.Component = componentModule; - for (const part of componentExport.split('.')) { - this.Component = this.Component[part]; - } - } - this.hydrator = hydrator; - return this.hydrate; - }, - opts, - this - ); - } catch (e) { - // eslint-disable-next-line no-console - console.error( - `[astro-island] Error hydrating ${this.getAttribute('component-url')}`, - e - ); - } - } - hydrate = async () => { - // The client directive needs to load the hydrator code before it can hydrate - if (!this.hydrator) return; - - // Make sure the island is mounted on the DOM before hydrating. It could be unmounted - // when the parent island hydrates and re-creates this island. - if (!this.isConnected) return; - - // Wait for parent island to hydrate first so we hydrate top-down. The `ssr` attribute - // represents that it has not completed hydration yet. - const parentSsrIsland = this.parentElement?.closest('astro-island[ssr]'); - if (parentSsrIsland) { - parentSsrIsland.addEventListener('astro:hydrate', this.hydrate, { once: true }); - return; + this.lastChild.remove(); + onConnected(); } + }); + mo.observe(this, { childList: true }); + // in case the marker comment got stripped and the mutation observer waited indefinitely, + // also wait for DOMContentLoaded as a last resort + document.addEventListener('DOMContentLoaded', onConnected); + } + } - const slotted = this.querySelectorAll('astro-slot'); - const slots: Record = {}; - // Always check to see if there are templates. - // This happens if slots were passed but the client component did not render them. - const templates = this.querySelectorAll('template[data-astro-template]'); - for (const template of templates) { - const closest = template.closest(this.tagName); - if (!closest?.isSameNode(this)) continue; - slots[template.getAttribute('data-astro-template') || 'default'] = template.innerHTML; - template.remove(); - } - for (const slot of slotted) { - const closest = slot.closest(this.tagName); - if (!closest?.isSameNode(this)) continue; - slots[slot.getAttribute('name') || 'default'] = slot.innerHTML; - } + async childrenConnectedCallback() { + let beforeHydrationUrl = this.getAttribute('before-hydration-url'); + if (beforeHydrationUrl) { + await import(beforeHydrationUrl); + } + this.start(); + } + + async start() { + const opts = JSON.parse(this.getAttribute('opts')!) as Record; + const directive = this.getAttribute('client') as directiveAstroKeys; + if (Astro[directive] === undefined) { + window.addEventListener(`astro:${directive}`, () => this.start(), { once: true }); + return; + } + try { + await Astro[directive]!( + async () => { + const rendererUrl = this.getAttribute('renderer-url'); + const [componentModule, { default: hydrator }] = await Promise.all([ + import(this.getAttribute('component-url')!), + rendererUrl ? import(rendererUrl) : () => () => {}, + ]); + const componentExport = this.getAttribute('component-export') || 'default'; + if (!componentExport.includes('.')) { + this.Component = componentModule[componentExport]; + } else { + this.Component = componentModule; + for (const part of componentExport.split('.')) { + this.Component = this.Component[part]; + } + } + this.hydrator = hydrator; + return this.hydrate; + }, + opts, + this + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`[astro-island] Error hydrating ${this.getAttribute('component-url')}`, e); + } + } - let props: Record; + hydrate = async () => { + // The client directive needs to load the hydrator code before it can hydrate + if (!this.hydrator) return; - try { - props = this.hasAttribute('props') - ? reviveObject(JSON.parse(this.getAttribute('props')!)) - : {}; - } catch (e) { - let componentName: string = this.getAttribute('component-url') || ''; - const componentExport = this.getAttribute('component-export'); + // Make sure the island is mounted on the DOM before hydrating. It could be unmounted + // when the parent island hydrates and re-creates this island. + if (!this.isConnected) return; - if (componentExport) { - componentName += ` (export ${componentExport})`; - } + // Wait for parent island to hydrate first so we hydrate top-down. The `ssr` attribute + // represents that it has not completed hydration yet. + const parentSsrIsland = this.parentElement?.closest('astro-island[ssr]'); + if (parentSsrIsland) { + parentSsrIsland.addEventListener('astro:hydrate', this.hydrate, { once: true }); + return; + } - // eslint-disable-next-line no-console - console.error( - `[hydrate] Error parsing props for component ${componentName}`, - this.getAttribute('props'), - e - ); - throw e; - } - let hydrationTimeStart; - const hydrator = this.hydrator(this); - if (process.env.NODE_ENV === 'development') hydrationTimeStart = performance.now(); - await hydrator(this.Component, props, slots, { - client: this.getAttribute('client'), - }); - if (process.env.NODE_ENV === 'development' && hydrationTimeStart) - this.setAttribute( - 'client-render-time', - (performance.now() - hydrationTimeStart).toString() - ); - this.removeAttribute('ssr'); - this.dispatchEvent(new CustomEvent('astro:hydrate')); - }; - attributeChangedCallback() { - this.hydrate(); + const slotted = this.querySelectorAll('astro-slot'); + const slots: Record = {}; + // Always check to see if there are templates. + // This happens if slots were passed but the client component did not render them. + const templates = this.querySelectorAll('template[data-astro-template]'); + for (const template of templates) { + const closest = template.closest(this.tagName); + if (!closest?.isSameNode(this)) continue; + slots[template.getAttribute('data-astro-template') || 'default'] = template.innerHTML; + template.remove(); + } + for (const slot of slotted) { + const closest = slot.closest(this.tagName); + if (!closest?.isSameNode(this)) continue; + slots[slot.getAttribute('name') || 'default'] = slot.innerHTML; + } + + let props: Record; + + try { + props = this.hasAttribute('props') + ? reviveObject(JSON.parse(this.getAttribute('props')!)) + : {}; + } catch (e) { + let componentName: string = this.getAttribute('component-url') || ''; + const componentExport = this.getAttribute('component-export'); + + if (componentExport) { + componentName += ` (export ${componentExport})`; } - unmount = () => { - // If element wasn't persisted, fire unmount event - if (!this.isConnected) this.dispatchEvent(new CustomEvent('astro:unmount')); - }; + + // eslint-disable-next-line no-console + console.error( + `[hydrate] Error parsing props for component ${componentName}`, + this.getAttribute('props'), + e + ); + throw e; } - ); + let hydrationTimeStart; + const hydrator = this.hydrator(this); + if (process.env.NODE_ENV === 'development') hydrationTimeStart = performance.now(); + await hydrator(this.Component, props, slots, { + client: this.getAttribute('client'), + }); + if (process.env.NODE_ENV === 'development' && hydrationTimeStart) + this.setAttribute( + 'client-render-time', + (performance.now() - hydrationTimeStart).toString() + ); + this.removeAttribute('ssr'); + this.dispatchEvent(new CustomEvent('astro:hydrate')); + }; + + attributeChangedCallback() { + this.hydrate(); + } + + unmount = () => { + // If element wasn't persisted, fire unmount event + if (!this.isConnected) this.dispatchEvent(new CustomEvent('astro:unmount')); + }; + } + + if (!customElements.get('astro-island')) { + customElements.define('astro-island', AstroIsland); } }