diff --git a/.changeset/ninety-boats-brake.md b/.changeset/ninety-boats-brake.md new file mode 100644 index 000000000000..30c13a8207a7 --- /dev/null +++ b/.changeset/ninety-boats-brake.md @@ -0,0 +1,9 @@ +--- +'@astrojs/react': patch +'@astrojs/preact': patch +'@astrojs/vue': patch +'@astrojs/solid-js': patch +'@astrojs/svelte': patch +--- + +Automatically unmount islands when `astro:unmount` is fired diff --git a/.changeset/perfect-socks-hammer.md b/.changeset/perfect-socks-hammer.md new file mode 100644 index 000000000000..baae63ffe8da --- /dev/null +++ b/.changeset/perfect-socks-hammer.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fire `astro:unmount` event when island is disconnected diff --git a/packages/astro/components/ViewTransitions.astro b/packages/astro/components/ViewTransitions.astro index 33741d535b4e..15bad445d817 100644 --- a/packages/astro/components/ViewTransitions.astro +++ b/packages/astro/components/ViewTransitions.astro @@ -163,18 +163,20 @@ const { fallback = 'animate' } = Astro.props as Props; // Everything left in the new head is new, append it all. document.head.append(...doc.head.children); - // Move over persist stuff in the body + // Persist elements in the existing body const oldBody = document.body; - document.body.replaceWith(doc.body); for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) { const id = el.getAttribute(PERSIST_ATTR); - const newEl = document.querySelector(`[${PERSIST_ATTR}="${id}"]`); + const newEl = doc.querySelector(`[${PERSIST_ATTR}="${id}"]`); if (newEl) { // The element exists in the new page, replace it with the element // from the old page so that state is preserved. newEl.replaceWith(el); } } + // Only replace the existing body *AFTER* persistent elements are moved over + // This avoids disconnecting `astro-island` nodes multiple times + document.body.replaceWith(doc.body); // Simulate scroll behavior of Safari and // Chromium based browsers (Chrome, Edge, Opera, ...) diff --git a/packages/astro/src/runtime/server/astro-island.ts b/packages/astro/src/runtime/server/astro-island.ts index 7be630d068b1..e0e09eaec6f3 100644 --- a/packages/astro/src/runtime/server/astro-island.ts +++ b/packages/astro/src/runtime/server/astro-island.ts @@ -51,6 +51,12 @@ declare const Astro: { public Component: any; public hydrator: any; static observedAttributes = ['props']; + disconnectedCallback() { + document.addEventListener('astro:after-swap', () => { + // If element wasn't persisted, fire unmount event + if (!this.isConnected) this.dispatchEvent(new CustomEvent('astro:unmount')) + }, { once: true }) + } connectedCallback() { if (!this.hasAttribute('await-children') || this.firstChild) { this.childrenConnectedCallback(); diff --git a/packages/integrations/preact/src/client.ts b/packages/integrations/preact/src/client.ts index f90614398886..ad24e886b4f4 100644 --- a/packages/integrations/preact/src/client.ts +++ b/packages/integrations/preact/src/client.ts @@ -1,6 +1,6 @@ -import { h, render, type JSX } from 'preact'; -import StaticHtml from './static-html.js'; import type { SignalLike } from './types'; +import { h, render, hydrate } from 'preact'; +import StaticHtml from './static-html.js'; const sharedSignalMap = new Map(); @@ -8,7 +8,8 @@ export default (element: HTMLElement) => async ( Component: any, props: Record, - { default: children, ...slotted }: Record + { default: children, ...slotted }: Record, + { client }: Record ) => { if (!element.hasAttribute('ssr')) return; for (const [key, value] of Object.entries(slotted)) { @@ -27,23 +28,13 @@ export default (element: HTMLElement) => } } - // eslint-disable-next-line @typescript-eslint/no-shadow - function Wrapper({ children }: { children: JSX.Element }) { - let attrs = Object.fromEntries( - Array.from(element.attributes).map((attr) => [attr.name, attr.value]) - ); - return h(element.localName, attrs, children); - } - - let parent = element.parentNode as Element; + const bootstrap = client !== 'only' ? hydrate : render; - render( - h( - Wrapper, - null, - h(Component, props, children != null ? h(StaticHtml, { value: children }) : children) - ), - parent, - element + bootstrap( + h(Component, props, children != null ? h(StaticHtml, { value: children }) : children), + element, ); + + // Preact has no "unmount" option, but you can use `render(null, element)` + element.addEventListener('astro:unmount', () => render(null, element), { once: true }) }; diff --git a/packages/integrations/react/client-v17.js b/packages/integrations/react/client-v17.js index 44310960303c..70bddc353b76 100644 --- a/packages/integrations/react/client-v17.js +++ b/packages/integrations/react/client-v17.js @@ -1,5 +1,5 @@ import { createElement } from 'react'; -import { render, hydrate } from 'react-dom'; +import { render, hydrate, unmountComponentAtNode } from 'react-dom'; import StaticHtml from './static-html.js'; export default (element) => @@ -12,8 +12,9 @@ export default (element) => props, children != null ? createElement(StaticHtml, { value: children }) : children ); - if (client === 'only') { - return render(componentEl, element); - } - return hydrate(componentEl, element); + + const isHydrate = client !== 'only'; + const bootstrap = isHydrate ? hydrate : render; + bootstrap(componentEl, element); + element.addEventListener('astro:unmount', () => unmountComponentAtNode(element), { once: true }); }; diff --git a/packages/integrations/react/client.js b/packages/integrations/react/client.js index d8948e7bb7a6..dbd32c0c5a54 100644 --- a/packages/integrations/react/client.js +++ b/packages/integrations/react/client.js @@ -31,10 +31,14 @@ export default (element) => } if (client === 'only') { return startTransition(() => { - createRoot(element).render(componentEl); + const root = createRoot(element); + root.render(componentEl); + element.addEventListener('astro:unmount', () => root.unmount(), { once: true }); }); } - return startTransition(() => { - hydrateRoot(element, componentEl, renderOptions); + startTransition(() => { + const root = hydrateRoot(element, componentEl, renderOptions); + root.render(componentEl); + element.addEventListener('astro:unmount', () => root.unmount(), { once: true }); }); }; diff --git a/packages/integrations/solid/src/client.ts b/packages/integrations/solid/src/client.ts index 730db0f517a1..66b3767ea9fd 100644 --- a/packages/integrations/solid/src/client.ts +++ b/packages/integrations/solid/src/client.ts @@ -9,7 +9,7 @@ export default (element: HTMLElement) => } if (!element.hasAttribute('ssr')) return; - const fn = client === 'only' ? render : hydrate; + const boostrap = client === 'only' ? render : hydrate; let _slots: Record = {}; if (Object.keys(slotted).length > 0) { @@ -30,7 +30,7 @@ export default (element: HTMLElement) => const { default: children, ...slots } = _slots; const renderId = element.dataset.solidRenderId; - fn( + const dispose = boostrap( () => createComponent(Component, { ...props, @@ -42,4 +42,6 @@ export default (element: HTMLElement) => renderId, } ); + + element.addEventListener('astro:unmount', () => dispose(), { once: true }) }; diff --git a/packages/integrations/svelte/client.js b/packages/integrations/svelte/client.js index 0d07ff2ba563..99612a580a6e 100644 --- a/packages/integrations/svelte/client.js +++ b/packages/integrations/svelte/client.js @@ -14,7 +14,7 @@ export default (target) => { try { if (import.meta.env.DEV) useConsoleFilter(); - new Component({ + const component = new Component({ target, props: { ...props, @@ -24,6 +24,8 @@ export default (target) => { hydrate: client !== 'only', $$inline: true, }); + + element.addEventListener('astro:unmount', () => component.$destroy(), { once: true }) } catch (e) { } finally { if (import.meta.env.DEV) finishUsingConsoleFilter(); diff --git a/packages/integrations/vue/client.js b/packages/integrations/vue/client.js index ca61116b2af1..8b2a5eede16c 100644 --- a/packages/integrations/vue/client.js +++ b/packages/integrations/vue/client.js @@ -21,15 +21,13 @@ export default (element) => content = h(Suspense, null, content); } - if (client === 'only') { - const app = createApp({ name, render: () => content }); - await setup(app); - app.mount(element, false); - } else { - const app = createSSRApp({ name, render: () => content }); - await setup(app); - app.mount(element, true); - } + const isHydrate = client !== 'only'; + const boostrap = isHydrate ? createSSRApp : createApp; + const app = boostrap({ name, render: () => content }); + await setup(app); + app.mount(element, isHydrate); + + element.addEventListener('astro:unmount', () => app.unmount(), { once: true }); }; function isAsync(fn) {