Skip to content

feat: POC HMR in Svelte 5 #10100

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

Closed
wants to merge 11 commits into from
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -333,15 +333,31 @@ export function client_component(source, analysis, options) {
const body = [
...state.hoisted,
...module.body,
b.export_default(
b.function_declaration(
b.id(analysis.name),
[b.id('$$anchor'), b.id('$$props')],
component_block
)
b.function_declaration(
b.id(analysis.name),
[b.id('$$anchor'), b.id('$$props')],
component_block
)
];

if (options.hmr) {
body.push(
b.export_default(
b.conditional(
b.import_meta_hot(),
b.call('$.hmr', b.member(b.import_meta_hot(), b.id('data')), b.id(analysis.name)),
b.id(analysis.name)
)
),
b.if(
b.import_meta_hot(),
b.stmt(b.call('import.meta.hot.acceptExports', b.literal('default')))
)
);
} else {
body.push(b.export_default(b.id(analysis.name)));
}

if (options.dev) {
if (options.filename) {
let filename = options.filename;
Expand Down
11 changes: 11 additions & 0 deletions packages/svelte/src/compiler/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,16 @@ export interface CompileOptions extends ModuleCompileOptions {
* @default null
*/
cssOutputFilename?: string;
/**
* If `true`, compiles components with hot reloading support.
*
* @default false
*/
hmr?: boolean;

// Other Svelte 4 compiler options:
// enableSourcemap?: EnableSourcemap; // TODO bring back? https://github.com/sveltejs/svelte/pull/6835
// legacy?: boolean; // TODO compiler error noting the new purpose?
}

export interface ModuleCompileOptions {
Expand Down Expand Up @@ -224,6 +234,7 @@ export type ValidatedCompileOptions = ValidatedModuleCompileOptions &
sourcemap: CompileOptions['sourcemap'];
legacy: Required<Required<CompileOptions>['legacy']>;
runes: CompileOptions['runes'];
hmr: CompileOptions['hmr'];
};

export type DeclarationKind =
Expand Down
14 changes: 14 additions & 0 deletions packages/svelte/src/compiler/utils/builders.js
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,20 @@ export function imports(parts, source) {
};
}

/**
* @return {import('estree').MemberExpression}
*/
export function import_meta_hot() {
return member(
{
type: 'MetaProperty',
meta: id('import'),
property: id('meta')
},
id('hot')
);
}

/**
* @param {import('estree').Expression | null} argument
* @returns {import('estree').ReturnStatement}
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/compiler/validate-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export const validate_component_options =
return input;
}),

hmr: boolean(false),

enableSourcemap: warn_removed(
'The enableSourcemap option has been removed. Source maps are always generated now, and tooling can choose to ignore them.'
),
Expand Down
196 changes: 196 additions & 0 deletions packages/svelte/src/internal/client/hmr.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { key_block } from './dom/blocks/key.js';
import { source, set, get, push, pop, user_effect } from './runtime.js';
import { current_hydration_fragment } from './hydration.js';
import { child_frag } from './operations.js';
import { STATE_SYMBOL, proxy } from './proxy.js';

/**
* @typedef {Record<string | symbol, any> | undefined} ComponentReturn
*
* @typedef {any[]} ComponentArgs
*
* @typedef {(...args: ComponentArgs) => ComponentReturn} Component
*
* @typedef {{
* set_component: (new_component: Component) => void
* proxy_component?: (...args: ComponentArgs) => ComponentReturn
* }} HotData<Component>
*/

function get_hydration_root() {
function find_surrounding_ssr_commments() {
if (!current_hydration_fragment?.[0]) return null;

/** @type {Comment | undefined} */
let before;
/** @type {Comment | undefined} */
let after;
/** @type {Node | null | undefined} */
let node;

node = current_hydration_fragment[0].previousSibling;
while (node) {
const comment = /** @type {Comment} */ (node);
if (node.nodeType === 8 && comment.data.startsWith('ssr:')) {
before = comment;
break;
}
node = node.previousSibling;
}

node = current_hydration_fragment.at(-1)?.nextSibling;
while (node) {
const comment = /** @type {Comment} */ (node);
if (node.nodeType === 8 && comment.data.startsWith('ssr:')) {
after = comment;
break;
}
node = node.nextSibling;
}

if (before && after && before.data === after.data) {
return [before, after];
}

return null;
}

if (current_hydration_fragment) {
const ssr0 = find_surrounding_ssr_commments();
if (ssr0) {
const [before, after] = ssr0;
current_hydration_fragment.unshift(before);
current_hydration_fragment.push(after);
return child_frag(current_hydration_fragment, false);
}
}
}

function create_accessors_proxy() {
const accessors_proxy = proxy(/** @type {import('./types.js').ProxyStateObject} */ ({}));
/** @type {Set<string>} */
const accessors_keys = new Set();

/**
* @param {ComponentReturn} new_accessors
*/
function sync_accessors_proxy(new_accessors) {
const removed_keys = new Set(accessors_keys);

if (new_accessors) {
for (const key in new_accessors) {
accessors_keys.add(key);
removed_keys.delete(key);

// current -> proxy
user_effect(() => {
accessors_proxy[key] = new_accessors[key];
});

// proxy -> current
const descriptor = Object.getOwnPropertyDescriptor(new_accessors, key);
if (descriptor?.set || descriptor?.writable) {
user_effect(() => {
const s = accessors_proxy[STATE_SYMBOL].s.get(key);
if (s) {
new_accessors[key] = get(s);
}
});
}
}
}

for (const key of removed_keys) {
accessors_keys.delete(key);
accessors_proxy[key] = undefined;
}
}

return { accessors_proxy, sync_accessors_proxy };
}

/**
* @param {Component} new_component
*/
function create_proxy_component(new_component) {
const component_signal = source(new_component);

let component_name = new_component.name;

/**
* @type {HotData["set_component"]}
*/
function set_component(new_component) {
component_name = new_component.name;
set(component_signal, new_component);
}

// @ts-ignore
function proxy_component($$anchor, $$props) {
push($$props);

const { accessors_proxy, sync_accessors_proxy } = create_accessors_proxy();

// During hydration the root component will receive a null $$anchor. The
// following is a hack to get our `key` a node to render to, all while
// avoiding it to "consume" the SSR marker.
//
// TODO better get the eyes of someone with understanding of hydration on this
//
// If this fails, we get an ugly hydration failure message, but HMR should
// still work after that... Maybe we can show a more specific error message than
// the generic hydration failure one (that could be misleading in this case).
//
if (!$$anchor) {
$$anchor = get_hydration_root() || $$anchor;
}

key_block(
$$anchor,
() => get(component_signal),
($$anchor) => {
const component = get(component_signal);

// @ts-ignore
const new_accessors = component($$anchor, $$props);

sync_accessors_proxy(new_accessors);
}
);

pop(accessors_proxy);

return accessors_proxy;
}

try {
Object.defineProperty(proxy_component, 'name', {
get() {
return component_name;
}
});
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[Svelte HMR] Failed to proxy component function's name", err);
}

return { proxy_component, set_component };
}

/**
* @param {HotData} hot_data
* @param {Component} new_component
*/
export function hmr(hot_data, new_component) {
if (hot_data.set_component) {
hot_data.set_component(new_component);
} else {
({
//
proxy_component: hot_data.proxy_component,
set_component: hot_data.set_component
} = create_proxy_component(new_component));
}

return hot_data.proxy_component;
}
2 changes: 2 additions & 0 deletions packages/svelte/src/internal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,6 @@ export {
$window as window,
$document as document
} from './client/operations.js';

export { hmr } from './client/hmr.js';
export { noop } from './common.js';
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import "svelte/internal/disclose-version";
import * as $ from "svelte/internal";

export default function Class_state_field_constructor_assignment($$anchor, $$props) {
function Class_state_field_constructor_assignment($$anchor, $$props) {
$.push($$props, true);

class Foo {
Expand All @@ -26,4 +26,6 @@ export default function Class_state_field_constructor_assignment($$anchor, $$pro
}

$.pop();
}
}

export default Class_state_field_constructor_assignment;
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as $ from "svelte/internal";

var frag = $.template(`<div></div> <svg></svg> <custom-element></custom-element> <div></div> <svg></svg> <custom-element></custom-element>`, true);

export default function Main($$anchor, $$props) {
function Main($$anchor, $$props) {
$.push($$props, true);

// needs to be a snapshot test because jsdom does auto-correct the attribute casing
Expand Down Expand Up @@ -45,4 +45,6 @@ export default function Main($$anchor, $$props) {

$.close_frag($$anchor, fragment);
$.pop();
}
}

export default Main;
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import "svelte/internal/disclose-version";
import * as $ from "svelte/internal";

export default function Function_prop_no_getter($$anchor, $$props) {
function Function_prop_no_getter($$anchor, $$props) {
$.push($$props, true);

let count = $.source(0);
Expand Down Expand Up @@ -33,4 +33,6 @@ export default function Function_prop_no_getter($$anchor, $$props) {

$.close_frag($$anchor, fragment);
$.pop();
}
}

export default Function_prop_no_getter;
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as $ from "svelte/internal";

var frag = $.template(`<h1>hello world</h1>`);

export default function Hello_world($$anchor, $$props) {
function Hello_world($$anchor, $$props) {
$.push($$props, false);
$.init();

Expand All @@ -14,4 +14,6 @@ export default function Hello_world($$anchor, $$props) {

$.close($$anchor, h1);
$.pop();
}
}

export default Hello_world;
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import "svelte/internal/disclose-version";
import * as $ from "svelte/internal";

export default function Svelte_element($$anchor, $$props) {
function Svelte_element($$anchor, $$props) {
$.push($$props, true);

let tag = $.prop($$props, "tag", 3, 'hr');
Expand All @@ -14,4 +14,6 @@ export default function Svelte_element($$anchor, $$props) {
$.element(node, tag, false);
$.close_frag($$anchor, fragment);
$.pop();
}
}

export default Svelte_element;
Loading