From 275540103440bb6c625b410d0626eea1494b0565 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Sun, 18 Feb 2024 13:52:56 +0100 Subject: [PATCH] breaking: remove `createRoot`, adjust `mount`/`hydrate` APIs, introduce `unmount` (#10516) * breaking: remove `createRoot`, adjust `mount`/`hydrate` APIs, introduce `unmount` closes #9827 * Update packages/svelte/src/internal/client/runtime.js Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> --------- Co-authored-by: Rich Harris --- .changeset/quiet-apricots-dream.md | 5 + packages/svelte/src/internal/client/render.js | 221 ++++++++---------- .../svelte/src/internal/client/runtime.js | 23 +- packages/svelte/src/legacy/legacy-client.js | 18 +- packages/svelte/src/main/main-client.js | 5 +- packages/svelte/src/main/main-server.js | 5 +- packages/svelte/tests/css/test.ts | 6 +- .../hydration/samples/noscript/_after.html | 2 + packages/svelte/tests/hydration/test.ts | 1 + .../oncreate/_config.js | 5 +- .../svelte/tests/runtime-browser/driver.js | 14 +- packages/svelte/tests/runtime-browser/test.ts | 4 + .../transition-css-iframe/Frame.svelte | 6 +- .../samples/transition-css-iframe/_config.js | 3 +- .../svelte/tests/runtime-legacy/shared.ts | 3 +- packages/svelte/tests/types/component.ts | 15 +- packages/svelte/types/index.d.ts | 41 ++-- playgrounds/demo/src/entry-client.ts | 7 +- .../03-appendix/02-breaking-changes.md | 58 ++++- 19 files changed, 253 insertions(+), 189 deletions(-) create mode 100644 .changeset/quiet-apricots-dream.md create mode 100644 packages/svelte/tests/hydration/samples/noscript/_after.html diff --git a/.changeset/quiet-apricots-dream.md b/.changeset/quiet-apricots-dream.md new file mode 100644 index 0000000000..18ffb66534 --- /dev/null +++ b/.changeset/quiet-apricots-dream.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +breaking: remove `createRoot`, adjust `mount`/`hydrate` APIs, introduce `unmount` diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index 9cc6b8225d..84b7c529b1 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -43,6 +43,7 @@ import { untrack, effect, flushSync, + flush_sync, safe_not_equal, current_block, managed_effect, @@ -64,12 +65,11 @@ import { get_descriptors, is_array, is_function, - object_assign, - object_keys + object_assign } from './utils.js'; import { is_promise } from '../common.js'; import { bind_transition, trigger_transitions } from './transitions.js'; -import { STATE_SYMBOL, proxy } from './proxy.js'; +import { STATE_SYMBOL } from './proxy.js'; /** @type {Set} */ const all_registerd_events = new Set(); @@ -2825,14 +2825,21 @@ export function spread_props(...props) { return new Proxy({ props }, spread_props_handler); } +// TODO 5.0 remove this /** - * Mounts the given component to the given target and returns a handle to the component's public accessors - * as well as a `$set` and `$destroy` method to update the props of the component or destroy it. - * - * If you don't need to interact with the component after mounting, use `mount` instead to save some bytes. + * @deprecated Use `mount` or `hydrate` instead + */ +export function createRoot() { + throw new Error( + '`createRoot` has been removed. Use `mount` or `hydrate` instead. See the updated docs for more info: https://svelte-5-preview.vercel.app/docs/breaking-changes#components-are-no-longer-classes' + ); +} + +/** + * Mounts a component to the given target and returns the exports and potentially the accessors (if compiled with `accessors: true`) of the component * * @template {Record} Props - * @template {Record | undefined} Exports + * @template {Record} Exports * @template {Record} Events * @param {import('../../main/public.js').ComponentType>} component * @param {{ @@ -2841,48 +2848,22 @@ export function spread_props(...props) { * events?: Events; * context?: Map; * intro?: boolean; - * recover?: false; * }} options - * @returns {Exports & { $destroy: () => void; $set: (props: Partial) => void; }} + * @returns {Exports} */ -export function createRoot(component, options) { - const props = proxy(/** @type {any} */ (options.props) || {}, false); - - let [accessors, $destroy] = hydrate(component, { ...options, props }); - - const result = - /** @type {Exports & { $destroy: () => void; $set: (props: Partial) => void; }} */ ({ - $set: (next) => { - object_assign(props, next); - }, - $destroy - }); - - for (const key of object_keys(accessors || {})) { - define_property(result, key, { - get() { - // @ts-expect-error TS doesn't know key exists on accessor - return accessors[key]; - }, - /** @param {any} value */ - set(value) { - // @ts-expect-error TS doesn't know key exists on accessor - flushSync(() => (accessors[key] = value)); - }, - enumerable: true - }); - } - - return result; +export function mount(component, options) { + init_operations(); + const anchor = empty(); + options.target.appendChild(anchor); + // Don't flush previous effects to ensure order of outer effects stays consistent + return flush_sync(() => _mount(component, { ...options, anchor }), false); } /** - * Mounts the given component to the given target and returns the accessors of the component and a function to destroy it. - * - * If you need to interact with the component after mounting, use `createRoot` instead. + * Hydrates a component on the given target and returns the exports and potentially the accessors (if compiled with `accessors: true`) of the component * * @template {Record} Props - * @template {Record | undefined} Exports + * @template {Record} Exports * @template {Record} Events * @param {import('../../main/public.js').ComponentType>} component * @param {{ @@ -2891,19 +2872,65 @@ export function createRoot(component, options) { * events?: Events; * context?: Map; * intro?: boolean; + * recover?: false; * }} options - * @returns {[Exports, () => void]} + * @returns {Exports} */ -export function mount(component, options) { +export function hydrate(component, options) { init_operations(); - const anchor = empty(); - options.target.appendChild(anchor); - return _mount(component, { ...options, anchor }); + const container = options.target; + const first_child = /** @type {ChildNode} */ (container.firstChild); + // Call with insert_text == true to prevent empty {expressions} resulting in an empty + // fragment array, resulting in a hydration error down the line + const hydration_fragment = get_hydration_fragment(first_child, true); + const previous_hydration_fragment = current_hydration_fragment; + set_current_hydration_fragment(hydration_fragment); + + /** @type {null | Text} */ + let anchor = null; + if (hydration_fragment === null) { + anchor = empty(); + container.appendChild(anchor); + } + + let finished_hydrating = false; + + try { + // Don't flush previous effects to ensure order of outer effects stays consistent + return flush_sync(() => { + const instance = _mount(component, { ...options, anchor }); + // flush_sync will run this callback and then synchronously run any pending effects, + // which don't belong to the hydration phase anymore - therefore reset it here + set_current_hydration_fragment(null); + finished_hydrating = true; + return instance; + }, false); + } catch (error) { + if (!finished_hydrating && options.recover !== false && hydration_fragment !== null) { + // eslint-disable-next-line no-console + console.error( + 'ERR_SVELTE_HYDRATION_MISMATCH' + + (DEV + ? ': Hydration failed because the initial UI does not match what was rendered on the server.' + : ''), + error + ); + remove(hydration_fragment); + first_child.remove(); + hydration_fragment.at(-1)?.nextSibling?.remove(); + set_current_hydration_fragment(null); + return mount(component, options); + } else { + throw error; + } + } finally { + set_current_hydration_fragment(previous_hydration_fragment); + } } /** * @template {Record} Props - * @template {Record | undefined} Exports + * @template {Record} Exports * @template {Record} Events * @param {import('../../main/public.js').ComponentType>} component * @param {{ @@ -2915,7 +2942,7 @@ export function mount(component, options) { * intro?: boolean; * recover?: false; * }} options - * @returns {[Exports, () => void]} + * @returns {Exports} */ function _mount(component, options) { const registered_events = new Set(); @@ -2934,7 +2961,7 @@ function _mount(component, options) { options.context; } // @ts-expect-error the public typings are not what the actual function looks like - accessors = component(options.anchor, options.props || {}); + accessors = component(options.anchor, options.props || {}) || {}; if (options.context) { pop(); } @@ -2981,80 +3008,38 @@ function _mount(component, options) { event_handle(array_from(all_registerd_events)); root_event_handles.add(event_handle); - return [ - accessors, - () => { - for (const event_name of registered_events) { - container.removeEventListener(event_name, bound_event_listener); - } - root_event_handles.delete(event_handle); - const dom = block.d; - if (dom !== null) { - remove(dom); - } - destroy_signal(/** @type {import('./types.js').EffectSignal} */ (block.e)); + mounted_components.set(accessors, () => { + for (const event_name of registered_events) { + container.removeEventListener(event_name, bound_event_listener); + } + root_event_handles.delete(event_handle); + const dom = block.d; + if (dom !== null) { + remove(dom); } - ]; + destroy_signal(/** @type {import('./types.js').EffectSignal} */ (block.e)); + }); + + return accessors; } /** - * Hydrates the given component to the given target and returns the accessors of the component and a function to destroy it. - * - * If you need to interact with the component after hydrating, use `createRoot` instead. - * - * @template {Record} Props - * @template {Record | undefined} Exports - * @template {Record} Events - * @param {import('../../main/public.js').ComponentType>} component - * @param {{ - * target: Node; - * props?: Props; - * events?: Events; - * context?: Map; - * intro?: boolean; - * recover?: false; - * }} options - * @returns {[Exports, () => void]} + * References of the accessors of all components that were `mount`ed or `hydrate`d. + * Uses a `WeakMap` to avoid memory leaks. */ -export function hydrate(component, options) { - init_operations(); - const container = options.target; - const first_child = /** @type {ChildNode} */ (container.firstChild); - // Call with insert_text == true to prevent empty {expressions} resulting in an empty - // fragment array, resulting in a hydration error down the line - const hydration_fragment = get_hydration_fragment(first_child, true); - const previous_hydration_fragment = current_hydration_fragment; +let mounted_components = new WeakMap(); - try { - /** @type {null | Text} */ - let anchor = null; - if (hydration_fragment === null) { - anchor = empty(); - container.appendChild(anchor); - } - set_current_hydration_fragment(hydration_fragment); - return _mount(component, { ...options, anchor }); - } catch (error) { - if (options.recover !== false && hydration_fragment !== null) { - // eslint-disable-next-line no-console - console.error( - 'ERR_SVELTE_HYDRATION_MISMATCH' + - (DEV - ? ': Hydration failed because the initial UI does not match what was rendered on the server.' - : ''), - error - ); - remove(hydration_fragment); - first_child.remove(); - hydration_fragment.at(-1)?.nextSibling?.remove(); - set_current_hydration_fragment(null); - return mount(component, options); - } else { - throw error; - } - } finally { - set_current_hydration_fragment(previous_hydration_fragment); +/** + * Unmounts a component that was previously mounted using `mount` or `hydrate`. + * @param {Record} component + */ +export function unmount(component) { + const destroy = mounted_components.get(component); + if (DEV && !destroy) { + // eslint-disable-next-line no-console + console.warn('Tried to unmount a component that was not mounted.'); } + destroy?.(); } /** diff --git a/packages/svelte/src/internal/client/runtime.js b/packages/svelte/src/internal/client/runtime.js index c47f0d4638..dbf0036e89 100644 --- a/packages/svelte/src/internal/client/runtime.js +++ b/packages/svelte/src/internal/client/runtime.js @@ -749,9 +749,22 @@ export function flush_local_pre_effects(context) { * @returns {void} */ export function flushSync(fn) { + flush_sync(fn); +} + +/** + * Internal version of `flushSync` with the option to not flush previous effects. + * Returns the result of the passed function, if given. + * @param {() => any} [fn] + * @param {boolean} [flush_previous] + * @returns {any} + */ +export function flush_sync(fn, flush_previous = true) { const previous_scheduler_mode = current_scheduler_mode; const previous_queued_pre_and_render_effects = current_queued_pre_and_render_effects; const previous_queued_effects = current_queued_effects; + let result; + try { infinite_loop_guard(); /** @type {import('./types.js').EffectSignal[]} */ @@ -762,10 +775,12 @@ export function flushSync(fn) { current_scheduler_mode = FLUSH_SYNC; current_queued_pre_and_render_effects = pre_and_render_effects; current_queued_effects = effects; - flush_queued_effects(previous_queued_pre_and_render_effects); - flush_queued_effects(previous_queued_effects); + if (flush_previous) { + flush_queued_effects(previous_queued_pre_and_render_effects); + flush_queued_effects(previous_queued_effects); + } if (fn !== undefined) { - fn(); + result = fn(); } if (current_queued_pre_and_render_effects.length > 0 || effects.length > 0) { flushSync(); @@ -782,6 +797,8 @@ export function flushSync(fn) { current_queued_pre_and_render_effects = previous_queued_pre_and_render_effects; current_queued_effects = previous_queued_effects; } + + return result; } /** diff --git a/packages/svelte/src/legacy/legacy-client.js b/packages/svelte/src/legacy/legacy-client.js index d4887422f9..312a62e24b 100644 --- a/packages/svelte/src/legacy/legacy-client.js +++ b/packages/svelte/src/legacy/legacy-client.js @@ -14,6 +14,7 @@ import * as $ from '../internal/index.js'; * @param {import('../main/public.js').ComponentConstructorOptions & { * component: import('../main/public.js').SvelteComponent; * immutable?: boolean; + * hydrate?: boolean; * recover?: false; * }} options * @returns {import('../main/public.js').SvelteComponent & Exports} @@ -53,28 +54,28 @@ class Svelte4Component { /** @type {any} */ #events = {}; - /** @type {ReturnType} */ + /** @type {Record} */ #instance; /** * @param {import('../main/public.js').ComponentConstructorOptions & { * component: any; * immutable?: boolean; + * hydrate?: boolean; * recover?: false; * }} options */ constructor(options) { - this.#instance = $.createRoot(options.component, { + const props = $.proxy({ ...(options.props || {}), $$events: this.#events }, false); + this.#instance = (options.hydrate ? $.hydrate : $.mount)(options.component, { target: options.target, - props: { ...options.props, $$events: this.#events }, + props, context: options.context, intro: options.intro, recover: options.recover }); for (const key of Object.keys(this.#instance)) { - if (key === '$set' || key === '$destroy') continue; - define_property(this, key, { get() { return this.#instance[key]; @@ -86,6 +87,13 @@ class Svelte4Component { enumerable: true }); } + + this.#instance.$set = /** @param {Record} next */ (next) => { + Object.assign(props, next); + }; + this.#instance.$destroy = () => { + $.unmount(this.#instance); + }; } /** @param {Record} props */ diff --git a/packages/svelte/src/main/main-client.js b/packages/svelte/src/main/main-client.js index 13c7872e62..b7c242fb82 100644 --- a/packages/svelte/src/main/main-client.js +++ b/packages/svelte/src/main/main-client.js @@ -235,10 +235,11 @@ function init_update_callbacks(context) { // (except probably untrack — do we want to expose that, if there's also a rune?) export { flushSync, - createRoot, mount, hydrate, tick, + unmount, untrack, - unstate + unstate, + createRoot } from '../internal/index.js'; diff --git a/packages/svelte/src/main/main-server.js b/packages/svelte/src/main/main-server.js index 3b02eb33cf..58ee20e9ae 100644 --- a/packages/svelte/src/main/main-server.js +++ b/packages/svelte/src/main/main-server.js @@ -1,7 +1,6 @@ import { on_destroy } from '../internal/server/index.js'; export { - createRoot, createEventDispatcher, flushSync, getAllContexts, @@ -11,7 +10,9 @@ export { hydrate, setContext, tick, - untrack + unmount, + untrack, + createRoot } from './main-client.js'; /** @returns {void} */ diff --git a/packages/svelte/tests/css/test.ts b/packages/svelte/tests/css/test.ts index 3eb54e160d..71b8e9644c 100644 --- a/packages/svelte/tests/css/test.ts +++ b/packages/svelte/tests/css/test.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import { assert } from 'vitest'; import { compile_directory, try_read_file } from '../helpers.js'; import { assert_html_equal } from '../html_equal.js'; -import { createRoot } from 'svelte'; +import { mount, unmount } from 'svelte'; import { suite, type BaseTest } from '../suite.js'; import type { CompileOptions, Warning } from '#compiler'; @@ -55,7 +55,7 @@ const { test, run } = suite(async (config, cwd) => { if (expected.html !== null) { const target = window.document.createElement('main'); - const { $destroy } = createRoot(ClientComponent, { props: config.props ?? {}, target }); + const component = mount(ClientComponent, { props: config.props ?? {}, target }); const html = target.innerHTML; @@ -63,7 +63,7 @@ const { test, run } = suite(async (config, cwd) => { assert_html_equal(html, expected.html); - $destroy(); + unmount(component); window.document.head.innerHTML = ''; // remove added styles // TODO enable SSR tests diff --git a/packages/svelte/tests/hydration/samples/noscript/_after.html b/packages/svelte/tests/hydration/samples/noscript/_after.html new file mode 100644 index 0000000000..be193b8c98 --- /dev/null +++ b/packages/svelte/tests/hydration/samples/noscript/_after.html @@ -0,0 +1,2 @@ + +

Hello!

Count: 1

diff --git a/packages/svelte/tests/hydration/test.ts b/packages/svelte/tests/hydration/test.ts index 3f7d2e5815..c9db5d7e6f 100644 --- a/packages/svelte/tests/hydration/test.ts +++ b/packages/svelte/tests/hydration/test.ts @@ -84,6 +84,7 @@ const { test, run } = suite(async (config, cwd) => { const component = createClassComponent({ component: (await import(`${cwd}/_output/client/main.svelte.js`)).default, target, + hydrate: true, props: config.props }); diff --git a/packages/svelte/tests/runtime-browser/custom-elements-samples/oncreate/_config.js b/packages/svelte/tests/runtime-browser/custom-elements-samples/oncreate/_config.js index a20e84c24a..4b8888b32b 100644 --- a/packages/svelte/tests/runtime-browser/custom-elements-samples/oncreate/_config.js +++ b/packages/svelte/tests/runtime-browser/custom-elements-samples/oncreate/_config.js @@ -5,11 +5,12 @@ export default test({ async test({ assert, target }) { target.innerHTML = ''; await tick(); + await tick(); + await tick(); + /** @type {any} */ const el = target.querySelector('my-app'); - await tick(); - assert.ok(el.wasCreated); assert.ok(el.propsInitialized); } diff --git a/packages/svelte/tests/runtime-browser/driver.js b/packages/svelte/tests/runtime-browser/driver.js index 29f6f5174a..7c8d32274d 100644 --- a/packages/svelte/tests/runtime-browser/driver.js +++ b/packages/svelte/tests/runtime-browser/driver.js @@ -24,12 +24,13 @@ export default async function (target) { component: SvelteComponent, target, props: config.props, - intro: config.intro + intro: config.intro, + hydrate: __HYDRATE__ }, config.options || {} ); - const component = createClassComponent(options); + const component = __CE_TEST__ ? null : createClassComponent(options); /** * @param {() => boolean} fn @@ -50,14 +51,19 @@ export default async function (target) { if (config.test) { await config.test({ assert, - component, + get component() { + if (!component) { + throw new Error('test property `component` is not available in custom element tests'); + } + return component; + }, componentCtor: SvelteComponent, target, window, waitUntil: wait_until }); - component.$destroy(); + component?.$destroy(); if (unhandled_rejection) { throw unhandled_rejection; diff --git a/packages/svelte/tests/runtime-browser/test.ts b/packages/svelte/tests/runtime-browser/test.ts index 8545f5292f..215b7fd415 100644 --- a/packages/svelte/tests/runtime-browser/test.ts +++ b/packages/svelte/tests/runtime-browser/test.ts @@ -67,6 +67,10 @@ async function run_test( const build_result = await build({ entryPoints: [`${__dirname}/driver.js`], write: false, + define: { + __HYDRATE__: String(hydrate), + __CE_TEST__: String(test_dir.includes('custom-elements-samples')) + }, alias: { __MAIN_DOT_SVELTE__: path.resolve(test_dir, 'main.svelte'), __CONFIG__: path.resolve(test_dir, '_config.js'), diff --git a/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/Frame.svelte b/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/Frame.svelte index 6a458a3b2f..d920734dd8 100644 --- a/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/Frame.svelte +++ b/packages/svelte/tests/runtime-legacy/samples/transition-css-iframe/Frame.svelte @@ -1,5 +1,6 @@