From f43e0762e5a03400a19cd3d5df26ca3141461f1d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 16 Feb 2024 14:52:22 +0100 Subject: [PATCH 1/7] feat: add hydrate method, make hydration treeshakeable Introduces a new `hydrate` method which does hydration. Refactors code so that hydration-related code is treeshaken out when not using that method. closes #9533 part of #9827 --- .changeset/gorgeous-singers-rest.md | 5 + .../svelte/scripts/check-treeshakeability.js | 98 ++++++-- packages/svelte/src/internal/client/each.js | 36 +-- .../svelte/src/internal/client/hydration.js | 31 ++- .../svelte/src/internal/client/operations.js | 11 +- .../svelte/src/internal/client/reconciler.js | 4 +- packages/svelte/src/internal/client/render.js | 229 +++++++++++------- packages/svelte/src/main/main-client.js | 10 +- packages/svelte/src/main/main-server.js | 1 + packages/svelte/types/index.d.ts | 13 + playgrounds/demo/src/entry-client.ts | 4 +- .../docs/content/01-api/05-functions.md | 41 ++++ 12 files changed, 335 insertions(+), 148 deletions(-) create mode 100644 .changeset/gorgeous-singers-rest.md diff --git a/.changeset/gorgeous-singers-rest.md b/.changeset/gorgeous-singers-rest.md new file mode 100644 index 000000000000..5625c2764a33 --- /dev/null +++ b/.changeset/gorgeous-singers-rest.md @@ -0,0 +1,5 @@ +--- +"svelte": patch +--- + +feat: add hydrate method, make hydration treeshakeable diff --git a/packages/svelte/scripts/check-treeshakeability.js b/packages/svelte/scripts/check-treeshakeability.js index fd55ce096eca..c30b31f90392 100644 --- a/packages/svelte/scripts/check-treeshakeability.js +++ b/packages/svelte/scripts/check-treeshakeability.js @@ -3,6 +3,33 @@ import path from 'node:path'; import { rollup } from 'rollup'; import virtual from '@rollup/plugin-virtual'; import { nodeResolve } from '@rollup/plugin-node-resolve'; +import { compile } from 'svelte/compiler'; + +async function bundle_code(entry) { + const bundle = await rollup({ + input: '__entry__', + plugins: [ + virtual({ + __entry__: entry + }), + nodeResolve({ + exportConditions: ['production', 'import', 'browser', 'default'] + }) + ], + onwarn: (warning, handle) => { + // if (warning.code !== 'EMPTY_BUNDLE') handle(warning); + } + }); + + const { output } = await bundle.generate({}); + + if (output.length > 1) { + throw new Error('errr what'); + } + + const code = output[0].code.trim(); + return code; +} const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); @@ -20,31 +47,8 @@ for (const key in pkg.exports) { if (!pkg.exports[key][type]) continue; const subpackage = path.join(pkg.name, key); - const resolved = path.resolve(pkg.exports[key][type]); - - const bundle = await rollup({ - input: '__entry__', - plugins: [ - virtual({ - __entry__: `import ${JSON.stringify(resolved)}` - }), - nodeResolve({ - exportConditions: ['production', 'import', 'browser', 'default'] - }) - ], - onwarn: (warning, handle) => { - // if (warning.code !== 'EMPTY_BUNDLE') handle(warning); - } - }); - - const { output } = await bundle.generate({}); - - if (output.length > 1) { - throw new Error('errr what'); - } - - const code = output[0].code.trim(); + const code = await bundle_code(`import ${JSON.stringify(resolved)}`); if (code === '') { // eslint-disable-next-line no-console @@ -59,6 +63,52 @@ for (const key in pkg.exports) { } } +const client_main = path.resolve(pkg.exports['.'].browser); +const without_hydration = await bundle_code( + // Use all features which contain hydration code to ensure it's treeshakeable + compile( + ` + + +hi + +a +a + + + + +{#if foo} +{/if} +{#each foo as bar} +{/each} +{#await foo} +{/await} +{#key foo} +{/key} +{#snippet x()} +{/snippet} + +{@render x()} +{@html foo} + `, + { filename: 'App.svelte' } + ).js.code +); +if (!without_hydration.includes('current_hydration_fragment')) { + // eslint-disable-next-line no-console + console.error(`✅ Hydration code treeshakeable`); +} else { + // eslint-disable-next-line no-console + console.error(without_hydration); + // eslint-disable-next-line no-console + console.error(`❌ Hydration code not treeshakeable`); + failed = true; +} + // eslint-disable-next-line no-console console.groupEnd(); diff --git a/packages/svelte/src/internal/client/each.js b/packages/svelte/src/internal/client/each.js index 25df8dce7362..2d81a0b070b1 100644 --- a/packages/svelte/src/internal/client/each.js +++ b/packages/svelte/src/internal/client/each.js @@ -11,6 +11,7 @@ import { current_hydration_fragment, get_hydration_fragment, hydrate_block_anchor, + hydrating, set_current_hydration_fragment } from './hydration.js'; import { clear_text_content, empty, map_get, map_set } from './operations.js'; @@ -61,7 +62,10 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re /** @type {null | import('./types.js').EffectSignal} */ let render = null; - /** Whether or not there was a "rendered fallback but want to render items" (or vice versa) hydration mismatch */ + /** + * Whether or not there was a "rendered fallback but want to render items" (or vice versa) hydration mismatch. + * Needs to be a `let` or else it isn't treeshaken out + */ let mismatch = false; block.r = @@ -107,7 +111,7 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re // If the each block is controlled, then the anchor node will be the surrounding // element in which the each block is rendered, which requires certain handling // depending on whether we're in hydration mode or not - if (current_hydration_fragment === null) { + if (!hydrating) { // Create a new anchor on the fly because there's none due to the optimization anchor = empty(); block.a.appendChild(anchor); @@ -153,13 +157,13 @@ function each(anchor_node, collection, flags, key_fn, render_fn, fallback_fn, re const length = array.length; - if (current_hydration_fragment !== null) { + if (hydrating) { const is_each_else_comment = /** @type {Comment} */ (current_hydration_fragment?.[0])?.data === 'ssr:each_else'; // Check for hydration mismatch which can happen if the server renders the each fallback // but the client has items, or vice versa. If so, remove everything inside the anchor and start fresh. if ((is_each_else_comment && length) || (!is_each_else_comment && !length)) { - remove(/** @type {import('./types.js').TemplateNode[]} */ (current_hydration_fragment)); + remove(current_hydration_fragment); set_current_hydration_fragment(null); mismatch = true; } else if (is_each_else_comment) { @@ -306,22 +310,22 @@ function reconcile_indexed_array( } } else { var item; - var is_hydrating = current_hydration_fragment !== null; + /** `true` if there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ + let mismatch = false; b_blocks = Array(b); - if (is_hydrating) { + if (hydrating) { // Hydrate block var hydration_list = /** @type {import('./types.js').TemplateNode[]} */ ( current_hydration_fragment ); var hydrating_node = hydration_list[0]; for (; index < length; index++) { - var fragment = /** @type {Array} */ ( - get_hydration_fragment(hydrating_node) - ); + var fragment = get_hydration_fragment(hydrating_node); set_current_hydration_fragment(fragment); if (!fragment) { // If fragment is null, then that means that the server rendered less items than what // the client code specifies -> break out and continue with client-side node creation + mismatch = true; break; } @@ -357,7 +361,7 @@ function reconcile_indexed_array( } } - if (is_hydrating && current_hydration_fragment === null) { + if (mismatch) { // Server rendered less nodes than the client -> set empty array so that Svelte continues to operate in hydration mode set_current_hydration_fragment([]); } @@ -425,9 +429,10 @@ function reconcile_tracked_array( var key; var item; var idx; - var is_hydrating = current_hydration_fragment !== null; + /** `true` if there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ + let mismatch = false; b_blocks = Array(b); - if (is_hydrating) { + if (hydrating) { // Hydrate block var fragment; var hydration_list = /** @type {import('./types.js').TemplateNode[]} */ ( @@ -435,13 +440,12 @@ function reconcile_tracked_array( ); var hydrating_node = hydration_list[0]; while (b > 0) { - fragment = /** @type {Array} */ ( - get_hydration_fragment(hydrating_node) - ); + fragment = get_hydration_fragment(hydrating_node); set_current_hydration_fragment(fragment); if (!fragment) { // If fragment is null, then that means that the server rendered less items than what // the client code specifies -> break out and continue with client-side node creation + mismatch = true; break; } @@ -594,7 +598,7 @@ function reconcile_tracked_array( } } - if (is_hydrating && current_hydration_fragment === null) { + if (mismatch) { // Server rendered less nodes than the client -> set empty array so that Svelte continues to operate in hydration mode set_current_hydration_fragment([]); } diff --git a/packages/svelte/src/internal/client/hydration.js b/packages/svelte/src/internal/client/hydration.js index a8067ff89f1b..3d37f88ec1f6 100644 --- a/packages/svelte/src/internal/client/hydration.js +++ b/packages/svelte/src/internal/client/hydration.js @@ -3,25 +3,37 @@ import { empty } from './operations.js'; import { schedule_task } from './runtime.js'; -/** @type {null | Array} */ -export let current_hydration_fragment = null; +/** + * Use this variable to guard everything related to hydration code so it can be treeshaken out + * if the user doesn't use the `hydrate` method and these code paths are therefore not needed. + */ +export let hydrating = false; + +/** + * Array of nodes to traverse for hydration. This will be null if we're not hydrating, but for + * the sake of simplicity we're not going to use `null` checks everywhere and instead rely on + * the `hydrating` flag to tell whether or not we're in hydration mode at which point this is set. + * @type {import('./types.js').TemplateNode[]} + */ +export let current_hydration_fragment = /** @type {any} */ (null); /** - * @param {null | Array} fragment + * @param {null | import('./types.js').TemplateNode[]} fragment * @returns {void} */ export function set_current_hydration_fragment(fragment) { - current_hydration_fragment = fragment; + hydrating = fragment !== null; + current_hydration_fragment = /** @type {import('./types.js').TemplateNode[]} */ (fragment); } /** * Returns all nodes between the first `` comment tag pair encountered. * @param {Node | null} node * @param {boolean} [insert_text] Whether to insert an empty text node if the fragment is empty - * @returns {Array | null} + * @returns {import('./types.js').TemplateNode[] | null} */ export function get_hydration_fragment(node, insert_text = false) { - /** @type {Array} */ + /** @type {import('./types.js').TemplateNode[]} */ const fragment = []; /** @type {null | Node} */ @@ -66,9 +78,10 @@ export function get_hydration_fragment(node, insert_text = false) { * @returns {void} */ export function hydrate_block_anchor(anchor_node, is_controlled) { - /** @type {Node} */ - let target_node = anchor_node; - if (current_hydration_fragment !== null) { + if (hydrating) { + /** @type {Node} */ + let target_node = anchor_node; + if (is_controlled) { target_node = /** @type {Node} */ (target_node.firstChild); } diff --git a/packages/svelte/src/internal/client/operations.js b/packages/svelte/src/internal/client/operations.js index 5b6b3f93e128..ce9ea3c8f5f6 100644 --- a/packages/svelte/src/internal/client/operations.js +++ b/packages/svelte/src/internal/client/operations.js @@ -1,4 +1,4 @@ -import { current_hydration_fragment, get_hydration_fragment } from './hydration.js'; +import { current_hydration_fragment, get_hydration_fragment, hydrating } from './hydration.js'; import { get_descriptor } from './utils.js'; // We cache the Node and Element prototype methods, so that we can avoid doing @@ -171,7 +171,7 @@ export function empty() { /*#__NO_SIDE_EFFECTS__*/ export function child(node) { const child = first_child_get.call(node); - if (current_hydration_fragment !== null) { + if (hydrating) { // Child can be null if we have an element with a single child, like `

{text}

`, where `text` is empty if (child === null) { const text = empty(); @@ -192,7 +192,7 @@ export function child(node) { */ /*#__NO_SIDE_EFFECTS__*/ export function child_frag(node, is_text) { - if (current_hydration_fragment !== null) { + if (hydrating) { const first_node = /** @type {Node[]} */ (node)[0]; // if an {expression} is empty during SSR, there might be no @@ -225,7 +225,7 @@ export function child_frag(node, is_text) { /*#__NO_SIDE_EFFECTS__*/ export function sibling(node, is_text = false) { const next_sibling = next_sibling_get.call(node); - if (current_hydration_fragment !== null) { + if (hydrating) { // if a sibling {expression} is empty during SSR, there might be no // text node to hydrate — we must therefore create one if (is_text && next_sibling?.nodeType !== 3) { @@ -276,6 +276,7 @@ export function create_element(name) { } /** + * Expects to only be called in hydration mode * @param {Node} node * @returns {Node} */ @@ -283,7 +284,7 @@ function capture_fragment_from_node(node) { if ( node.nodeType === 8 && /** @type {Comment} */ (node).data.startsWith('ssr:') && - /** @type {Array} */ (current_hydration_fragment).at(-1) !== node + current_hydration_fragment.at(-1) !== node ) { const fragment = /** @type {Array} */ (get_hydration_fragment(node)); const last_child = fragment.at(-1) || node; diff --git a/packages/svelte/src/internal/client/reconciler.js b/packages/svelte/src/internal/client/reconciler.js index 848e7205a3f4..7f0aa36a33dc 100644 --- a/packages/svelte/src/internal/client/reconciler.js +++ b/packages/svelte/src/internal/client/reconciler.js @@ -1,5 +1,5 @@ import { append_child } from './operations.js'; -import { current_hydration_fragment, hydrate_block_anchor } from './hydration.js'; +import { current_hydration_fragment, hydrate_block_anchor, hydrating } from './hydration.js'; import { is_array } from './utils.js'; /** @param {string} html */ @@ -92,7 +92,7 @@ export function remove(current) { */ export function reconcile_html(target, value, svg) { hydrate_block_anchor(target); - if (current_hydration_fragment !== null) { + if (hydrating) { return current_hydration_fragment; } var html = value + ''; diff --git a/packages/svelte/src/internal/client/render.js b/packages/svelte/src/internal/client/render.js index f9be124243f3..51807c014317 100644 --- a/packages/svelte/src/internal/client/render.js +++ b/packages/svelte/src/internal/client/render.js @@ -54,6 +54,7 @@ import { current_hydration_fragment, get_hydration_fragment, hydrate_block_anchor, + hydrating, set_current_hydration_fragment } from './hydration.js'; import { @@ -166,7 +167,7 @@ export function svg_replace(node) { * @returns {Element | DocumentFragment | Node[]} */ function open_template(is_fragment, use_clone_node, anchor, template_element_fn) { - if (current_hydration_fragment !== null) { + if (hydrating) { if (anchor !== null) { hydrate_block_anchor(anchor, false); } @@ -217,7 +218,7 @@ export function space(anchor) { // if an {expression} is empty during SSR, there might be no // text node to hydrate (or an anchor comment is falsely detected instead) // — we must therefore create one - if (current_hydration_fragment !== null && node?.nodeType !== 3) { + if (hydrating && node?.nodeType !== 3) { node = empty(); // @ts-ignore in this case the anchor should always be a comment, // if not something more fundamental is wrong and throwing here is better to bail out early @@ -251,10 +252,8 @@ function close_template(dom, is_fragment, anchor) { ? dom : /** @type {import('./types.js').TemplateNode[]} */ (Array.from(dom.childNodes)) : dom; - if (anchor !== null) { - if (current_hydration_fragment === null) { - insert(current, null, anchor); - } + if (!hydrating && anchor !== null) { + insert(current, null, anchor); } block.d = current; } @@ -415,14 +414,13 @@ export function class_name(dom, value) { // @ts-expect-error need to add __className to patched prototype const prev_class_name = dom.__className; const next_class_name = to_class(value); - const is_hydrating = current_hydration_fragment !== null; - if (is_hydrating && dom.className === next_class_name) { + if (hydrating && dom.className === next_class_name) { // In case of hydration don't reset the class as it's already correct. // @ts-expect-error need to add __className to patched prototype dom.__className = next_class_name; } else if ( prev_class_name !== next_class_name || - (is_hydrating && dom.className !== next_class_name) + (hydrating && dom.className !== next_class_name) ) { if (next_class_name === '') { dom.removeAttribute('class'); @@ -452,7 +450,7 @@ export function text(dom, value) { // @ts-expect-error need to add __value to patched prototype const prev_node_value = dom.__nodeValue; const next_node_value = stringify(value); - if (current_hydration_fragment !== null && dom.nodeValue === next_node_value) { + if (hydrating && dom.nodeValue === next_node_value) { // In case of hydration don't reset the nodeValue as it's already correct. // @ts-expect-error need to add __nodeValue to patched prototype dom.__nodeValue = next_node_value; @@ -739,7 +737,7 @@ export function bind_playback_rate(media, get_value, update) { * @param {(paused: boolean) => void} update */ export function bind_paused(media, get_value, update) { - let mounted = current_hydration_fragment !== null; + let mounted = hydrating; let paused = get_value(); const callback = () => { if (paused !== media.paused) { @@ -1452,7 +1450,8 @@ export function slot(anchor_node, slot_fn, slot_props, fallback_fn) { function if_block(anchor_node, condition_fn, consequent_fn, alternate_fn) { const block = create_if_block(); hydrate_block_anchor(anchor_node); - const previous_hydration_fragment = current_hydration_fragment; + /** Whether or not there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */ + let mismatch = false; /** @type {null | import('./types.js').TemplateNode | Array} */ let consequent_dom = null; @@ -1495,7 +1494,7 @@ function if_block(anchor_node, condition_fn, consequent_fn, alternate_fn) { trigger_transitions(alternate_transitions, 'in'); } } - } else if (current_hydration_fragment !== null) { + } else if (hydrating) { const comment_text = /** @type {Comment} */ (current_hydration_fragment?.[0])?.data; if ( !comment_text || @@ -1506,6 +1505,7 @@ function if_block(anchor_node, condition_fn, consequent_fn, alternate_fn) { // This could happen using when `{#if browser} .. {/if}` in SvelteKit. remove(current_hydration_fragment); set_current_hydration_fragment(null); + mismatch = true; } else { // Remove the ssr:if comment node or else it will confuse the subsequent hydration algorithm current_hydration_fragment.shift(); @@ -1530,9 +1530,9 @@ function if_block(anchor_node, condition_fn, consequent_fn, alternate_fn) { } if (result && current_branch_effect !== consequent_effect) { consequent_fn(anchor_node); - if (current_branch_effect === null) { - // Restore previous fragment so that Svelte continues to operate in hydration mode - set_current_hydration_fragment(previous_hydration_fragment); + if (mismatch && current_branch_effect === null) { + // Set fragment so that Svelte continues to operate in hydration mode + set_current_hydration_fragment([]); } current_branch_effect = consequent_effect; consequent_dom = block.d; @@ -1558,9 +1558,9 @@ function if_block(anchor_node, condition_fn, consequent_fn, alternate_fn) { if (alternate_fn !== null) { alternate_fn(anchor_node); } - if (current_branch_effect === null) { - // Restore previous fragment so that Svelte continues to operate in hydration mode - set_current_hydration_fragment(previous_hydration_fragment); + if (mismatch && current_branch_effect === null) { + // Set fragment so that Svelte continues to operate in hydration mode + set_current_hydration_fragment([]); } current_branch_effect = alternate_effect; alternate_dom = block.d; @@ -1593,10 +1593,15 @@ export function head(render_fn) { const block = create_head_block(); // The head function may be called after the first hydration pass and ssr comment nodes may still be present, // therefore we need to skip that when we detect that we're not in hydration mode. - const hydration_fragment = - current_hydration_fragment !== null ? get_hydration_fragment(document.head.firstChild) : null; - const previous_hydration_fragment = current_hydration_fragment; - set_current_hydration_fragment(hydration_fragment); + let hydration_fragment = null; + let previous_hydration_fragment = null; + let is_hydrating = hydrating; + if (is_hydrating) { + hydration_fragment = get_hydration_fragment(document.head.firstChild); + previous_hydration_fragment = current_hydration_fragment; + set_current_hydration_fragment(hydration_fragment); + } + try { const head_effect = render_effect( () => { @@ -1606,7 +1611,7 @@ export function head(render_fn) { block.d = null; } let anchor = null; - if (current_hydration_fragment === null) { + if (!hydrating) { anchor = empty(); document.head.appendChild(anchor); } @@ -1623,7 +1628,9 @@ export function head(render_fn) { }); block.e = head_effect; } finally { - set_current_hydration_fragment(previous_hydration_fragment); + if (is_hydrating) { + set_current_hydration_fragment(previous_hydration_fragment); + } } } @@ -1688,7 +1695,7 @@ export function element(anchor_node, tag_fn, is_svg, render_fn) { ? null : anchor_node.parentElement?.namespaceURI ?? null; const next_element = tag - ? current_hydration_fragment !== null + ? hydrating ? /** @type {Element} */ (current_hydration_fragment[0]) : ns ? document.createElementNS(ns, tag) @@ -1701,7 +1708,7 @@ export function element(anchor_node, tag_fn, is_svg, render_fn) { element = next_element; if (element !== null && render_fn !== undefined) { let anchor; - if (current_hydration_fragment !== null) { + if (hydrating) { // Use the existing ssr comment as the anchor so that the inner open and close // methods can pick up the existing nodes correctly anchor = /** @type {Comment} */ (element.firstChild); @@ -2158,7 +2165,7 @@ export function cssProps(anchor, is_html, props, component) { /** @type {Text | Comment} */ let component_anchor; - if (current_hydration_fragment !== null) { + if (hydrating) { // Hydration: css props element is surrounded by a ssr comment ... tag = /** @type {HTMLElement | SVGElement} */ (current_hydration_fragment[0]); // ... and the child(ren) of the css props element is also surround by a ssr comment @@ -2324,7 +2331,7 @@ export function action(dom, action, value_fn) { * @returns {void} */ export function remove_input_attr_defaults(dom) { - if (current_hydration_fragment !== null) { + if (hydrating) { attr(dom, 'value', null); attr(dom, 'checked', null); } @@ -2336,7 +2343,7 @@ export function remove_input_attr_defaults(dom) { * @returns {void} */ export function remove_textarea_child(dom) { - if (current_hydration_fragment !== null && dom.firstChild !== null) { + if (hydrating && dom.firstChild !== null) { dom.textContent = ''; } } @@ -2366,7 +2373,7 @@ export function attr(dom, attribute, value) { } if ( - current_hydration_fragment === null || + !hydrating || (dom.getAttribute(attribute) !== value && // If we reset those, they would result in another network request, which we want to avoid. // We assume they are the same between client and server as checking if they are equal is expensive @@ -2429,7 +2436,7 @@ export function srcset_url_equal(element, srcset) { * @param {string | null} value */ function check_src_in_dev_hydration(dom, attribute, value) { - if (!current_hydration_fragment) return; + if (!hydrating) return; if (attribute !== 'src' && attribute !== 'href' && attribute !== 'srcset') return; if (attribute === 'srcset' && srcset_url_equal(dom, value)) return; @@ -2642,7 +2649,7 @@ export function spread_attributes(dom, prev, attrs, lowercase_attributes, css_ha check_src_in_dev_hydration(dom, name, value); } if ( - current_hydration_fragment === null || + !hydrating || // @ts-ignore see attr method for an explanation of src/srcset (dom[name] !== value && name !== 'src' && name !== 'href' && name !== 'srcset') ) { @@ -2828,7 +2835,7 @@ export function spread_props(...props) { export function createRoot(component, options) { const props = proxy(/** @type {any} */ (options.props) || {}, false); - let [accessors, $destroy] = mount(component, { ...options, props }); + let [accessors, $destroy] = hydrate(component, { ...options, props }); const result = /** @type {Exports & { $destroy: () => void; $set: (props: Partial) => void; }} */ ({ @@ -2871,71 +2878,58 @@ export function createRoot(component, options) { * events?: Events; * context?: Map; * intro?: boolean; - * recover?: false; * }} options * @returns {[Exports, () => void]} */ export function mount(component, options) { init_operations(); + const anchor = empty(); + options.target.appendChild(anchor); + return _mount(component, { ...options, anchor }); +} + +/** + * @template {Record} Props + * @template {Record | undefined} Exports + * @template {Record} Events + * @param {import('../../main/public.js').ComponentType>} component + * @param {{ + * target: Node; + * anchor: null | Text; + * props?: Props; + * events?: Events; + * context?: Map; + * intro?: boolean; + * recover?: false; + * }} options + * @returns {[Exports, () => void]} + */ +function _mount(component, options) { const registered_events = new Set(); const container = options.target; const block = create_root_block(options.intro || false); - 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; /** @type {Exports} */ // @ts-expect-error will be defined because the render effect runs synchronously let accessors = undefined; - try { - /** @type {null | Text} */ - let anchor = null; - if (hydration_fragment === null) { - anchor = empty(); - container.appendChild(anchor); - } - set_current_hydration_fragment(hydration_fragment); - const effect = render_effect( - () => { - if (options.context) { - push({}); - /** @type {import('../client/types.js').ComponentContext} */ ( - current_component_context - ).c = options.context; - } - // @ts-expect-error the public typings are not what the actual function looks like - accessors = component(anchor, options.props || {}); - if (options.context) { - pop(); - } - }, - block, - true - ); - block.e = effect; - } 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(); - return mount(component, options); - } else { - throw error; - } - } finally { - set_current_hydration_fragment(previous_hydration_fragment); - } + const effect = render_effect( + () => { + if (options.context) { + push({}); + /** @type {import('../client/types.js').ComponentContext} */ (current_component_context).c = + options.context; + } + // @ts-expect-error the public typings are not what the actual function looks like + accessors = component(options.anchor, options.props || {}); + if (options.context) { + pop(); + } + }, + block, + true + ); + block.e = effect; const bound_event_listener = handle_event_propagation.bind(null, container); const bound_document_event_listener = handle_event_propagation.bind(null, document); @@ -2985,14 +2979,71 @@ export function mount(component, options) { if (dom !== null) { remove(dom); } - if (hydration_fragment !== null) { - remove(hydration_fragment); - } destroy_signal(/** @type {import('./types.js').EffectSignal} */ (block.e)); } ]; } +/** + * 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]} + */ +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; + + 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); + } +} + /** * @param {Record} props * @returns {void} diff --git a/packages/svelte/src/main/main-client.js b/packages/svelte/src/main/main-client.js index 2c6702671030..13c7872e6223 100644 --- a/packages/svelte/src/main/main-client.js +++ b/packages/svelte/src/main/main-client.js @@ -233,4 +233,12 @@ function init_update_callbacks(context) { // TODO bring implementations in here // (except probably untrack — do we want to expose that, if there's also a rune?) -export { flushSync, createRoot, mount, tick, untrack, unstate } from '../internal/index.js'; +export { + flushSync, + createRoot, + mount, + hydrate, + tick, + untrack, + unstate +} from '../internal/index.js'; diff --git a/packages/svelte/src/main/main-server.js b/packages/svelte/src/main/main-server.js index a9e1148cc3e5..3b02eb33cfcb 100644 --- a/packages/svelte/src/main/main-server.js +++ b/packages/svelte/src/main/main-server.js @@ -8,6 +8,7 @@ export { getContext, hasContext, mount, + hydrate, setContext, tick, untrack diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index caf1bf6d7326..bd382e28db8f 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -353,6 +353,19 @@ declare module 'svelte' { events?: Events | undefined; context?: Map | undefined; intro?: boolean | undefined; + }): [Exports, () => void]; + /** + * 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. + * + * */ + export function hydrate, Exports extends Record | undefined, Events extends Record>(component: ComponentType>, options: { + target: Node; + props?: Props | undefined; + events?: Events | undefined; + context?: Map | undefined; + intro?: boolean | undefined; recover?: false | undefined; }): [Exports, () => void]; /** diff --git a/playgrounds/demo/src/entry-client.ts b/playgrounds/demo/src/entry-client.ts index b29d004b1711..c996ffc02fcc 100644 --- a/playgrounds/demo/src/entry-client.ts +++ b/playgrounds/demo/src/entry-client.ts @@ -1,8 +1,8 @@ // @ts-ignore -import { mount } from 'svelte'; +import { hydrate } from 'svelte'; // @ts-ignore you need to create this file import App from './App.svelte'; // @ts-ignore -[window.unmount] = mount(App, { +[window.unmount] = hydrate(App, { target: document.getElementById('root')! }); diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md index 26410e1774a2..4a864b583aea 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md @@ -43,3 +43,44 @@ To remove reactivity from objects and arrays created with `$state`, use `unstate This is handy when you want to pass some state to an external library or API that doesn't expect a reactive object – such as `structuredClone`. > Note that `unstate` will return a new object from the input when removing reactivity. If the object passed isn't reactive, it will be returned as is. + +## `mount` + +Instantiates a component and mounts it to the given target: + +```js +import { mount } from 'svelte'; +import App from './App.svelte'; + +const app = mount(App, { + target: document.querySelector('#app'), + props: { some: 'property' } +}); +``` + +## `hydrate` + +Like `mount`, but will pick up any HTML rendered by Svelte's SSR output (from the `render` function) inside the target and makes it interactive: + +```js +import { hydrate } from 'svelte'; +import App from './App.svelte'; + +const app = hydrate(App, { + target: document.querySelector('#app'), + props: { some: 'property' } +}); +``` + +## `render` + +Only available on the server and when compiling with the `server` option. Takes a component and returns an object with `html` and `head` properties on it, which you can use to populate the HTML when server-rendering your app: + +```js +import { render } from 'svelte/server'; +import App from './App.svelte'; + +const result = render(App, { + props: { some: 'property' } +}); +``` From c97fc30237994a58688de013c103e14ec52376bd Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 16 Feb 2024 15:00:30 +0100 Subject: [PATCH 2/7] get docs building --- .../src/routes/docs/content/01-api/05-functions.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md index 4a864b583aea..4b2ca030a69d 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md @@ -49,6 +49,7 @@ This is handy when you want to pass some state to an external library or API tha Instantiates a component and mounts it to the given target: ```js +// @errors: 2724 import { mount } from 'svelte'; import App from './App.svelte'; @@ -63,6 +64,7 @@ const app = mount(App, { Like `mount`, but will pick up any HTML rendered by Svelte's SSR output (from the `render` function) inside the target and makes it interactive: ```js +// @errors: 2724 import { hydrate } from 'svelte'; import App from './App.svelte'; @@ -77,6 +79,7 @@ const app = hydrate(App, { Only available on the server and when compiling with the `server` option. Takes a component and returns an object with `html` and `head` properties on it, which you can use to populate the HTML when server-rendering your app: ```js +// @errors: 2724 import { render } from 'svelte/server'; import App from './App.svelte'; From 1fcf65ea91615704ff09b1f59645bb55773f157c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 16 Feb 2024 15:07:45 +0100 Subject: [PATCH 3/7] ugh --- .../src/routes/docs/content/01-api/05-functions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md index 4b2ca030a69d..ccde62ac5b81 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md @@ -49,7 +49,7 @@ This is handy when you want to pass some state to an external library or API tha Instantiates a component and mounts it to the given target: ```js -// @errors: 2724 +// @errors: 2724 2305 import { mount } from 'svelte'; import App from './App.svelte'; @@ -64,7 +64,7 @@ const app = mount(App, { Like `mount`, but will pick up any HTML rendered by Svelte's SSR output (from the `render` function) inside the target and makes it interactive: ```js -// @errors: 2724 +// @errors: 2724 2305 import { hydrate } from 'svelte'; import App from './App.svelte'; @@ -79,7 +79,7 @@ const app = hydrate(App, { Only available on the server and when compiling with the `server` option. Takes a component and returns an object with `html` and `head` properties on it, which you can use to populate the HTML when server-rendering your app: ```js -// @errors: 2724 +// @errors: 2724 2305 import { render } from 'svelte/server'; import App from './App.svelte'; From 35ff241a8f2ee8f03e522b053be551f18aabbce0 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 16 Feb 2024 15:12:30 +0100 Subject: [PATCH 4/7] one more --- .../src/routes/docs/content/01-api/05-functions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md index ccde62ac5b81..d7e08bace86b 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md @@ -79,7 +79,7 @@ const app = hydrate(App, { Only available on the server and when compiling with the `server` option. Takes a component and returns an object with `html` and `head` properties on it, which you can use to populate the HTML when server-rendering your app: ```js -// @errors: 2724 2305 +// @errors: 2724 2305 2307 import { render } from 'svelte/server'; import App from './App.svelte'; From a71f2ff177f7403db66e19e3f90ee8ba3b853de4 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 16 Feb 2024 18:12:59 -0500 Subject: [PATCH 5/7] Update packages/svelte/scripts/check-treeshakeability.js Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> --- packages/svelte/scripts/check-treeshakeability.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/svelte/scripts/check-treeshakeability.js b/packages/svelte/scripts/check-treeshakeability.js index c30b31f90392..3a3c55208cfa 100644 --- a/packages/svelte/scripts/check-treeshakeability.js +++ b/packages/svelte/scripts/check-treeshakeability.js @@ -27,8 +27,7 @@ async function bundle_code(entry) { throw new Error('errr what'); } - const code = output[0].code.trim(); - return code; + return output[0].code.trim(); } const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); From 367fa6be87cf4bf2fa45e327657469482eef28ce Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 16 Feb 2024 18:17:23 -0500 Subject: [PATCH 6/7] warn --- packages/svelte/scripts/check-treeshakeability.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/svelte/scripts/check-treeshakeability.js b/packages/svelte/scripts/check-treeshakeability.js index c30b31f90392..01cd592ec87c 100644 --- a/packages/svelte/scripts/check-treeshakeability.js +++ b/packages/svelte/scripts/check-treeshakeability.js @@ -17,7 +17,9 @@ async function bundle_code(entry) { }) ], onwarn: (warning, handle) => { - // if (warning.code !== 'EMPTY_BUNDLE') handle(warning); + if (warning.code !== 'EMPTY_BUNDLE' && warning.code !== 'CIRCULAR_DEPENDENCY') { + handle(warning); + } } }); From c2dfc0c1a8549f2f28b7ce3ff5364a0d2650f1cd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 17 Feb 2024 11:25:02 -0500 Subject: [PATCH 7/7] Update sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md --- .../src/routes/docs/content/01-api/05-functions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md index d7e08bace86b..278a71db7fb4 100644 --- a/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md +++ b/sites/svelte-5-preview/src/routes/docs/content/01-api/05-functions.md @@ -61,7 +61,7 @@ const app = mount(App, { ## `hydrate` -Like `mount`, but will pick up any HTML rendered by Svelte's SSR output (from the `render` function) inside the target and makes it interactive: +Like `mount`, but will pick up any HTML rendered by Svelte's SSR output (from the `render` function) inside the target and make it interactive: ```js // @errors: 2724 2305