Skip to content

Commit

Permalink
feat: add hydrate method, make hydration treeshakeable
Browse files Browse the repository at this point in the history
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
  • Loading branch information
dummdidumm committed Feb 16, 2024
1 parent 9e98bb6 commit f43e076
Show file tree
Hide file tree
Showing 12 changed files with 335 additions and 148 deletions.
5 changes: 5 additions & 0 deletions .changeset/gorgeous-singers-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

feat: add hydrate method, make hydration treeshakeable
98 changes: 74 additions & 24 deletions packages/svelte/scripts/check-treeshakeability.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand All @@ -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
Expand All @@ -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(
`
<script>
import { mount } from ${JSON.stringify(client_main)}; mount();
let foo;
</script>
<svelte:head><title>hi</title></svelte:head>
<a href={foo} class={foo}>a</a>
<a {...foo}>a</a>
<svelte:component this={foo} />
<svelte:element this={foo} />
<C {foo} />
{#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();

Expand Down
36 changes: 20 additions & 16 deletions packages/svelte/src/internal/client/each.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Text | Comment | Element>} */ (
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;
}

Expand Down Expand Up @@ -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([]);
}
Expand Down Expand Up @@ -425,23 +429,23 @@ 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[]} */ (
current_hydration_fragment
);
var hydrating_node = hydration_list[0];
while (b > 0) {
fragment = /** @type {Array<Text | Comment | Element>} */ (
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;
}

Expand Down Expand Up @@ -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([]);
}
Expand Down
31 changes: 22 additions & 9 deletions packages/svelte/src/internal/client/hydration.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,37 @@
import { empty } from './operations.js';
import { schedule_task } from './runtime.js';

/** @type {null | Array<import('./types.js').TemplateNode>} */
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<import('./types.js').TemplateNode>} 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 `<!--ssr:...-->` 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<import('./types.js').TemplateNode> | null}
* @returns {import('./types.js').TemplateNode[] | null}
*/
export function get_hydration_fragment(node, insert_text = false) {
/** @type {Array<import('./types.js').TemplateNode>} */
/** @type {import('./types.js').TemplateNode[]} */
const fragment = [];

/** @type {null | Node} */
Expand Down Expand Up @@ -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);
}
Expand Down
11 changes: 6 additions & 5 deletions packages/svelte/src/internal/client/operations.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 `<p>{text}</p>`, where `text` is empty
if (child === null) {
const text = empty();
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -276,14 +276,15 @@ export function create_element(name) {
}

/**
* Expects to only be called in hydration mode
* @param {Node} node
* @returns {Node}
*/
function capture_fragment_from_node(node) {
if (
node.nodeType === 8 &&
/** @type {Comment} */ (node).data.startsWith('ssr:') &&
/** @type {Array<Element | Text | Comment>} */ (current_hydration_fragment).at(-1) !== node
current_hydration_fragment.at(-1) !== node
) {
const fragment = /** @type {Array<Element | Text | Comment>} */ (get_hydration_fragment(node));
const last_child = fragment.at(-1) || node;
Expand Down
4 changes: 2 additions & 2 deletions packages/svelte/src/internal/client/reconciler.js
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -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 + '';
Expand Down
Loading

0 comments on commit f43e076

Please sign in to comment.