From b0e3c5b438f61d36710f141555aadde68f6e733b Mon Sep 17 00:00:00 2001
From: Rich Harris <rich.harris@vercel.com>
Date: Sat, 14 Dec 2024 09:05:47 -0500
Subject: [PATCH] feat: add outro option to unmount (#14540)

* feat: add outro option to unmount

* unused

* regenerate

* revert

* changeset

* create separate component_root effect

* docs

* return a promise

* remove from map immediately
---
 .changeset/sour-jeans-talk.md                 |  5 +++
 .../06-runtime/04-imperative-component-api.md | 11 ++++---
 .../src/internal/client/reactivity/effects.js | 24 ++++++++++++++
 packages/svelte/src/internal/client/render.js | 33 +++++++++++++++----
 packages/svelte/types/index.d.ts              | 18 +++++++++-
 5 files changed, 80 insertions(+), 11 deletions(-)
 create mode 100644 .changeset/sour-jeans-talk.md

diff --git a/.changeset/sour-jeans-talk.md b/.changeset/sour-jeans-talk.md
new file mode 100644
index 0000000000..7eaebde1f6
--- /dev/null
+++ b/.changeset/sour-jeans-talk.md
@@ -0,0 +1,5 @@
+---
+'svelte': patch
+---
+
+feat: add `outro` option to `unmount`
diff --git a/documentation/docs/06-runtime/04-imperative-component-api.md b/documentation/docs/06-runtime/04-imperative-component-api.md
index ffd03d85a6..16a2c8151c 100644
--- a/documentation/docs/06-runtime/04-imperative-component-api.md
+++ b/documentation/docs/06-runtime/04-imperative-component-api.md
@@ -33,19 +33,22 @@ Note that unlike calling `new App(...)` in Svelte 4, things like effects (includ
 
 ## `unmount`
 
-Unmounts a component created with [`mount`](#mount) or [`hydrate`](#hydrate):
+Unmounts a component that was previously created with [`mount`](#mount) or [`hydrate`](#hydrate).
+
+If `options.outro` is `true`, [transitions](transition) will play before the component is removed from the DOM:
 
 ```js
-// @errors: 1109
 import { mount, unmount } from 'svelte';
 import App from './App.svelte';
 
-const app = mount(App, {...});
+const app = mount(App, { target: document.body });
 
 // later
-unmount(app);
+unmount(app, { outro: true });
 ```
 
+Returns a `Promise` that resolves after transitions have completed if `options.outro` is true, or immediately otherwise.
+
 ## `render`
 
 Only available on the server and when compiling with the `server` option. Takes a component and returns an object with `body` and `head` properties on it, which you can use to populate the HTML when server-rendering your app:
diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js
index 77502edb90..05c210978d 100644
--- a/packages/svelte/src/internal/client/reactivity/effects.js
+++ b/packages/svelte/src/internal/client/reactivity/effects.js
@@ -248,11 +248,35 @@ export function inspect_effect(fn) {
  */
 export function effect_root(fn) {
 	const effect = create_effect(ROOT_EFFECT, fn, true);
+
 	return () => {
 		destroy_effect(effect);
 	};
 }
 
+/**
+ * An effect root whose children can transition out
+ * @param {() => void} fn
+ * @returns {(options?: { outro?: boolean }) => Promise<void>}
+ */
+export function component_root(fn) {
+	const effect = create_effect(ROOT_EFFECT, fn, true);
+
+	return (options = {}) => {
+		return new Promise((fulfil) => {
+			if (options.outro) {
+				pause_effect(effect, () => {
+					destroy_effect(effect);
+					fulfil(undefined);
+				});
+			} else {
+				destroy_effect(effect);
+				fulfil(undefined);
+			}
+		});
+	};
+}
+
 /**
  * @param {() => void | (() => void)} fn
  * @returns {Effect}
diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js
index 6e86968c7a..f4b7944c14 100644
--- a/packages/svelte/src/internal/client/render.js
+++ b/packages/svelte/src/internal/client/render.js
@@ -10,7 +10,7 @@ import {
 } from './dom/operations.js';
 import { HYDRATION_END, HYDRATION_ERROR, HYDRATION_START } from '../../constants.js';
 import { push, pop, component_context, active_effect } from './runtime.js';
-import { effect_root, branch } from './reactivity/effects.js';
+import { component_root, branch } from './reactivity/effects.js';
 import {
 	hydrate_next,
 	hydrate_node,
@@ -204,7 +204,7 @@ function _mount(Component, { target, anchor, props = {}, events, context, intro
 	// @ts-expect-error will be defined because the render effect runs synchronously
 	var component = undefined;
 
-	var unmount = effect_root(() => {
+	var unmount = component_root(() => {
 		var anchor_node = anchor ?? target.appendChild(create_text());
 
 		branch(() => {
@@ -252,7 +252,7 @@ function _mount(Component, { target, anchor, props = {}, events, context, intro
 			}
 
 			root_event_handles.delete(event_handle);
-			mounted_components.delete(component);
+
 			if (anchor_node !== anchor) {
 				anchor_node.parentNode?.removeChild(anchor_node);
 			}
@@ -271,14 +271,35 @@ let mounted_components = new WeakMap();
 
 /**
  * Unmounts a component that was previously mounted using `mount` or `hydrate`.
+ *
+ * If `options.outro` is `true`, [transitions](https://svelte.dev/docs/svelte/transition) will play before the component is removed from the DOM.
+ *
+ * Returns a `Promise` that resolves after transitions have completed if `options.outro` is true, or immediately otherwise.
+ *
+ * ```js
+ * import { mount, unmount } from 'svelte';
+ * import App from './App.svelte';
+ *
+ * const app = mount(App, { target: document.body });
+ *
+ * // later...
+ * unmount(app, { outro: true });
+ * ```
  * @param {Record<string, any>} component
+ * @param {{ outro?: boolean }} [options]
+ * @returns {Promise<void>}
  */
-export function unmount(component) {
+export function unmount(component, options) {
 	const fn = mounted_components.get(component);
 
 	if (fn) {
-		fn();
-	} else if (DEV) {
+		mounted_components.delete(component);
+		return fn(options);
+	}
+
+	if (DEV) {
 		w.lifecycle_double_unmount();
 	}
+
+	return Promise.resolve();
 }
diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts
index 562ce95998..bafa7a2265 100644
--- a/packages/svelte/types/index.d.ts
+++ b/packages/svelte/types/index.d.ts
@@ -448,8 +448,24 @@ declare module 'svelte' {
 	}): Exports;
 	/**
 	 * Unmounts a component that was previously mounted using `mount` or `hydrate`.
+	 *
+	 * If `options.outro` is `true`, [transitions](https://svelte.dev/docs/svelte/transition) will play before the component is removed from the DOM.
+	 *
+	 * Returns a `Promise` that resolves after transitions have completed if `options.outro` is true, or immediately otherwise.
+	 *
+	 * ```js
+	 * import { mount, unmount } from 'svelte';
+	 * import App from './App.svelte';
+	 *
+	 * const app = mount(App, { target: document.body });
+	 *
+	 * // later...
+	 * unmount(app, { outro: true });
+	 * ```
 	 * */
-	export function unmount(component: Record<string, any>): void;
+	export function unmount(component: Record<string, any>, options?: {
+		outro?: boolean;
+	} | undefined): Promise<void>;
 	/**
 	 * Returns a promise that resolves once any pending state changes have been applied.
 	 * */