Skip to content

Commit

Permalink
Unmount framework components when islands are destroyed (#8264)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
natemoo-re authored Aug 29, 2023
1 parent 9e021a9 commit 1f58a7a
Show file tree
Hide file tree
Showing 10 changed files with 63 additions and 43 deletions.
9 changes: 9 additions & 0 deletions .changeset/ninety-boats-brake.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/perfect-socks-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fire `astro:unmount` event when island is disconnected
8 changes: 5 additions & 3 deletions packages/astro/components/ViewTransitions.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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, ...)
Expand Down
6 changes: 6 additions & 0 deletions packages/astro/src/runtime/server/astro-island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
31 changes: 11 additions & 20 deletions packages/integrations/preact/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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<string, SignalLike>();

export default (element: HTMLElement) =>
async (
Component: any,
props: Record<string, any>,
{ default: children, ...slotted }: Record<string, any>
{ default: children, ...slotted }: Record<string, any>,
{ client }: Record<string, string>
) => {
if (!element.hasAttribute('ssr')) return;
for (const [key, value] of Object.entries(slotted)) {
Expand All @@ -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 })
};
11 changes: 6 additions & 5 deletions packages/integrations/react/client-v17.js
Original file line number Diff line number Diff line change
@@ -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) =>
Expand All @@ -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 });
};
10 changes: 7 additions & 3 deletions packages/integrations/react/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
};
6 changes: 4 additions & 2 deletions packages/integrations/solid/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {};
if (Object.keys(slotted).length > 0) {
Expand All @@ -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,
Expand All @@ -42,4 +42,6 @@ export default (element: HTMLElement) =>
renderId,
}
);

element.addEventListener('astro:unmount', () => dispose(), { once: true })
};
4 changes: 3 additions & 1 deletion packages/integrations/svelte/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default (target) => {
try {
if (import.meta.env.DEV) useConsoleFilter();

new Component({
const component = new Component({
target,
props: {
...props,
Expand All @@ -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();
Expand Down
16 changes: 7 additions & 9 deletions packages/integrations/vue/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit 1f58a7a

Please sign in to comment.