Skip to content

Commit 1f58a7a

Browse files
authored
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
1 parent 9e021a9 commit 1f58a7a

10 files changed

Lines changed: 63 additions & 43 deletions

File tree

.changeset/ninety-boats-brake.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@astrojs/react': patch
3+
'@astrojs/preact': patch
4+
'@astrojs/vue': patch
5+
'@astrojs/solid-js': patch
6+
'@astrojs/svelte': patch
7+
---
8+
9+
Automatically unmount islands when `astro:unmount` is fired

.changeset/perfect-socks-hammer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fire `astro:unmount` event when island is disconnected

packages/astro/components/ViewTransitions.astro

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,18 +163,20 @@ const { fallback = 'animate' } = Astro.props as Props;
163163
// Everything left in the new head is new, append it all.
164164
document.head.append(...doc.head.children);
165165

166-
// Move over persist stuff in the body
166+
// Persist elements in the existing body
167167
const oldBody = document.body;
168-
document.body.replaceWith(doc.body);
169168
for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) {
170169
const id = el.getAttribute(PERSIST_ATTR);
171-
const newEl = document.querySelector(`[${PERSIST_ATTR}="${id}"]`);
170+
const newEl = doc.querySelector(`[${PERSIST_ATTR}="${id}"]`);
172171
if (newEl) {
173172
// The element exists in the new page, replace it with the element
174173
// from the old page so that state is preserved.
175174
newEl.replaceWith(el);
176175
}
177176
}
177+
// Only replace the existing body *AFTER* persistent elements are moved over
178+
// This avoids disconnecting `astro-island` nodes multiple times
179+
document.body.replaceWith(doc.body);
178180

179181
// Simulate scroll behavior of Safari and
180182
// Chromium based browsers (Chrome, Edge, Opera, ...)

packages/astro/src/runtime/server/astro-island.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ declare const Astro: {
5151
public Component: any;
5252
public hydrator: any;
5353
static observedAttributes = ['props'];
54+
disconnectedCallback() {
55+
document.addEventListener('astro:after-swap', () => {
56+
// If element wasn't persisted, fire unmount event
57+
if (!this.isConnected) this.dispatchEvent(new CustomEvent('astro:unmount'))
58+
}, { once: true })
59+
}
5460
connectedCallback() {
5561
if (!this.hasAttribute('await-children') || this.firstChild) {
5662
this.childrenConnectedCallback();
Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import { h, render, type JSX } from 'preact';
2-
import StaticHtml from './static-html.js';
31
import type { SignalLike } from './types';
2+
import { h, render, hydrate } from 'preact';
3+
import StaticHtml from './static-html.js';
44

55
const sharedSignalMap = new Map<string, SignalLike>();
66

77
export default (element: HTMLElement) =>
88
async (
99
Component: any,
1010
props: Record<string, any>,
11-
{ default: children, ...slotted }: Record<string, any>
11+
{ default: children, ...slotted }: Record<string, any>,
12+
{ client }: Record<string, string>
1213
) => {
1314
if (!element.hasAttribute('ssr')) return;
1415
for (const [key, value] of Object.entries(slotted)) {
@@ -27,23 +28,13 @@ export default (element: HTMLElement) =>
2728
}
2829
}
2930

30-
// eslint-disable-next-line @typescript-eslint/no-shadow
31-
function Wrapper({ children }: { children: JSX.Element }) {
32-
let attrs = Object.fromEntries(
33-
Array.from(element.attributes).map((attr) => [attr.name, attr.value])
34-
);
35-
return h(element.localName, attrs, children);
36-
}
37-
38-
let parent = element.parentNode as Element;
31+
const bootstrap = client !== 'only' ? hydrate : render;
3932

40-
render(
41-
h(
42-
Wrapper,
43-
null,
44-
h(Component, props, children != null ? h(StaticHtml, { value: children }) : children)
45-
),
46-
parent,
47-
element
33+
bootstrap(
34+
h(Component, props, children != null ? h(StaticHtml, { value: children }) : children),
35+
element,
4836
);
37+
38+
// Preact has no "unmount" option, but you can use `render(null, element)`
39+
element.addEventListener('astro:unmount', () => render(null, element), { once: true })
4940
};
Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createElement } from 'react';
2-
import { render, hydrate } from 'react-dom';
2+
import { render, hydrate, unmountComponentAtNode } from 'react-dom';
33
import StaticHtml from './static-html.js';
44

55
export default (element) =>
@@ -12,8 +12,9 @@ export default (element) =>
1212
props,
1313
children != null ? createElement(StaticHtml, { value: children }) : children
1414
);
15-
if (client === 'only') {
16-
return render(componentEl, element);
17-
}
18-
return hydrate(componentEl, element);
15+
16+
const isHydrate = client !== 'only';
17+
const bootstrap = isHydrate ? hydrate : render;
18+
bootstrap(componentEl, element);
19+
element.addEventListener('astro:unmount', () => unmountComponentAtNode(element), { once: true });
1920
};

packages/integrations/react/client.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@ export default (element) =>
3131
}
3232
if (client === 'only') {
3333
return startTransition(() => {
34-
createRoot(element).render(componentEl);
34+
const root = createRoot(element);
35+
root.render(componentEl);
36+
element.addEventListener('astro:unmount', () => root.unmount(), { once: true });
3537
});
3638
}
37-
return startTransition(() => {
38-
hydrateRoot(element, componentEl, renderOptions);
39+
startTransition(() => {
40+
const root = hydrateRoot(element, componentEl, renderOptions);
41+
root.render(componentEl);
42+
element.addEventListener('astro:unmount', () => root.unmount(), { once: true });
3943
});
4044
};

packages/integrations/solid/src/client.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export default (element: HTMLElement) =>
99
}
1010
if (!element.hasAttribute('ssr')) return;
1111

12-
const fn = client === 'only' ? render : hydrate;
12+
const boostrap = client === 'only' ? render : hydrate;
1313

1414
let _slots: Record<string, any> = {};
1515
if (Object.keys(slotted).length > 0) {
@@ -30,7 +30,7 @@ export default (element: HTMLElement) =>
3030
const { default: children, ...slots } = _slots;
3131
const renderId = element.dataset.solidRenderId;
3232

33-
fn(
33+
const dispose = boostrap(
3434
() =>
3535
createComponent(Component, {
3636
...props,
@@ -42,4 +42,6 @@ export default (element: HTMLElement) =>
4242
renderId,
4343
}
4444
);
45+
46+
element.addEventListener('astro:unmount', () => dispose(), { once: true })
4547
};

packages/integrations/svelte/client.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export default (target) => {
1414
try {
1515
if (import.meta.env.DEV) useConsoleFilter();
1616

17-
new Component({
17+
const component = new Component({
1818
target,
1919
props: {
2020
...props,
@@ -24,6 +24,8 @@ export default (target) => {
2424
hydrate: client !== 'only',
2525
$$inline: true,
2626
});
27+
28+
element.addEventListener('astro:unmount', () => component.$destroy(), { once: true })
2729
} catch (e) {
2830
} finally {
2931
if (import.meta.env.DEV) finishUsingConsoleFilter();

packages/integrations/vue/client.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,13 @@ export default (element) =>
2121
content = h(Suspense, null, content);
2222
}
2323

24-
if (client === 'only') {
25-
const app = createApp({ name, render: () => content });
26-
await setup(app);
27-
app.mount(element, false);
28-
} else {
29-
const app = createSSRApp({ name, render: () => content });
30-
await setup(app);
31-
app.mount(element, true);
32-
}
24+
const isHydrate = client !== 'only';
25+
const boostrap = isHydrate ? createSSRApp : createApp;
26+
const app = boostrap({ name, render: () => content });
27+
await setup(app);
28+
app.mount(element, isHydrate);
29+
30+
element.addEventListener('astro:unmount', () => app.unmount(), { once: true });
3331
};
3432

3533
function isAsync(fn) {

0 commit comments

Comments
 (0)