Skip to content

feat: add hydrate method, make hydration treeshakeable #10497

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
99 changes: 75 additions & 24 deletions packages/svelte/scripts/check-treeshakeability.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,34 @@ 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' && warning.code !== 'CIRCULAR_DEPENDENCY') {
handle(warning);
}
}
});

const { output } = await bundle.generate({});

if (output.length > 1) {
throw new Error('errr what');
}

return output[0].code.trim();
}

const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));

Expand All @@ -20,31 +48,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 +64,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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this (and similar) guards? surely we're already only calling functions like hydrate_block_anchor when we're hydrating. rollup is smart enough to deal with it

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depends on the method, in this case there's no if block at the call site because it would result in more if block code overall.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what I was talking about last week in our call. I wasn't seeing the tree shaking working as expected by Rollup. However, if you put into this boolean guard then it works great – something I didn't do.

/** @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