diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index 16b7b476fe82..db6e2ddd8b50 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -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; diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index 11e71f63a10f..033ce10f35ab 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -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 { @@ -224,6 +234,7 @@ export type ValidatedCompileOptions = ValidatedModuleCompileOptions & sourcemap: CompileOptions['sourcemap']; legacy: Required['legacy']>; runes: CompileOptions['runes']; + hmr: CompileOptions['hmr']; }; export type DeclarationKind = diff --git a/packages/svelte/src/compiler/utils/builders.js b/packages/svelte/src/compiler/utils/builders.js index bc6df3a8f061..4bd14bf99d1c 100644 --- a/packages/svelte/src/compiler/utils/builders.js +++ b/packages/svelte/src/compiler/utils/builders.js @@ -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} diff --git a/packages/svelte/src/compiler/validate-options.js b/packages/svelte/src/compiler/validate-options.js index 1151ad0dc11e..181a2c7ab914 100644 --- a/packages/svelte/src/compiler/validate-options.js +++ b/packages/svelte/src/compiler/validate-options.js @@ -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.' ), diff --git a/packages/svelte/src/internal/client/hmr.js b/packages/svelte/src/internal/client/hmr.js new file mode 100644 index 000000000000..9b79ac62eb1a --- /dev/null +++ b/packages/svelte/src/internal/client/hmr.js @@ -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 | undefined} ComponentReturn + * + * @typedef {any[]} ComponentArgs + * + * @typedef {(...args: ComponentArgs) => ComponentReturn} Component + * + * @typedef {{ + * set_component: (new_component: Component) => void + * proxy_component?: (...args: ComponentArgs) => ComponentReturn + * }} HotData + */ + +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} */ + 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; +} diff --git a/packages/svelte/src/internal/index.js b/packages/svelte/src/internal/index.js index 72026e9389f1..c768b2680692 100644 --- a/packages/svelte/src/internal/index.js +++ b/packages/svelte/src/internal/index.js @@ -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'; diff --git a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js index 38a74ef9bb87..5df2e33d9f15 100644 --- a/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/class-state-field-constructor-assignment/_expected/client/index.svelte.js @@ -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 { @@ -26,4 +26,6 @@ export default function Class_state_field_constructor_assignment($$anchor, $$pro } $.pop(); -} \ No newline at end of file +} + +export default Class_state_field_constructor_assignment; diff --git a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js index 6494d2436a09..889117aa6af5 100644 --- a/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js +++ b/packages/svelte/tests/snapshot/samples/dynamic-attributes-casing/_expected/client/main.svelte.js @@ -5,7 +5,7 @@ import * as $ from "svelte/internal"; var frag = $.template(`
`, 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 @@ -45,4 +45,6 @@ export default function Main($$anchor, $$props) { $.close_frag($$anchor, fragment); $.pop(); -} \ No newline at end of file +} + +export default Main; diff --git a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js index f34a4933a345..97f4f9812220 100644 --- a/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/function-prop-no-getter/_expected/client/index.svelte.js @@ -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); @@ -33,4 +33,6 @@ export default function Function_prop_no_getter($$anchor, $$props) { $.close_frag($$anchor, fragment); $.pop(); -} \ No newline at end of file +} + +export default Function_prop_no_getter; diff --git a/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js index f57dd8a9fdc0..6fbc915a4bf9 100644 --- a/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/hello-world/_expected/client/index.svelte.js @@ -5,7 +5,7 @@ import * as $ from "svelte/internal"; var frag = $.template(`

hello world

`); -export default function Hello_world($$anchor, $$props) { +function Hello_world($$anchor, $$props) { $.push($$props, false); $.init(); @@ -14,4 +14,6 @@ export default function Hello_world($$anchor, $$props) { $.close($$anchor, h1); $.pop(); -} \ No newline at end of file +} + +export default Hello_world; diff --git a/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js b/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js index b2734b8c4648..257bb47d66ed 100644 --- a/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js +++ b/packages/svelte/tests/snapshot/samples/svelte-element/_expected/client/index.svelte.js @@ -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'); @@ -14,4 +14,6 @@ export default function Svelte_element($$anchor, $$props) { $.element(node, tag, false); $.close_frag($$anchor, fragment); $.pop(); -} \ No newline at end of file +} + +export default Svelte_element; diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index d3fb1f340d58..db3a6f73af1f 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -671,6 +671,16 @@ declare module 'svelte/compiler' { * @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? } interface ModuleCompileOptions { @@ -2412,6 +2422,16 @@ declare module 'svelte/types/compiler/interfaces' { * @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? } interface ModuleCompileOptions { diff --git a/playgrounds/demo/vite.config.js b/playgrounds/demo/vite.config.js index de42e9cfd4a5..3755f768c811 100644 --- a/playgrounds/demo/vite.config.js +++ b/playgrounds/demo/vite.config.js @@ -2,7 +2,13 @@ import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; export default defineConfig({ - plugins: [svelte()], + plugins: [ + svelte({ + compilerOptions: { + hmr: true + } + }) + ], optimizeDeps: { // svelte is a local workspace package, optimizing it would require dev server restarts with --force for every change exclude: ['svelte']