From 1f58a7a1bea6888868b689dac94801d554319b02 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 29 Aug 2023 09:30:11 -0500 Subject: [PATCH] Unmount framework components when islands are destroyed (#8264) * fix(view-transitions): update persistence logic for improved unmount behavior * feat(astro): add `astro:unmount` event * feat(vue): automatically unmount islands * feat(react): automatically unmount islands * feat(react): automatically unmount islands * feat(solid): automatically dispose of islands * feat(svelte): automatically destroy of islands * feat(svelte): automatically destroy of islands * feat(solid): automatically dispose of islands * feat(preact): automatically unmount islands * chore: update changeset * fix: rebase issue * chore: add clarifying comment * chore: remove duplicate changeset * chore: add changeset --- .changeset/ninety-boats-brake.md | 9 ++++++ .changeset/perfect-socks-hammer.md | 5 +++ .../astro/components/ViewTransitions.astro | 8 +++-- .../astro/src/runtime/server/astro-island.ts | 6 ++++ packages/integrations/preact/src/client.ts | 31 +++++++------------ packages/integrations/react/client-v17.js | 11 ++++--- packages/integrations/react/client.js | 10 ++++-- packages/integrations/solid/src/client.ts | 6 ++-- packages/integrations/svelte/client.js | 4 ++- packages/integrations/vue/client.js | 16 +++++----- 10 files changed, 63 insertions(+), 43 deletions(-) create mode 100644 .changeset/ninety-boats-brake.md create mode 100644 .changeset/perfect-socks-hammer.md diff --git a/.changeset/ninety-boats-brake.md b/.changeset/ninety-boats-brake.md new file mode 100644 index 0000000000..30c13a8207 --- /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 0000000000..baae63ffe8 --- /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 33741d535b..15bad445d8 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 7be630d068..e0e09eaec6 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 f906143988..ad24e886b4 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); - } + const bootstrap = client !== 'only' ? hydrate : render; - let parent = element.parentNode as Element; - - 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 4431096030..70bddc353b 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 d8948e7bb7..dbd32c0c5a 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 730db0f517..66b3767ea9 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 0d07ff2ba5..99612a580a 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 ca61116b2a..8b2a5eede1 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) {