Repository: sveltejs/svelte (JavaScript)
Date: 2019-07-01 15:39:03+00:00
Task type: Feature

Issue:
Hooks for granular HMR support
Follow up to recent conversation on discord regarding v3 support for HMR.

Let me list out the steps we need for a successful hot reload, and then maybe we can discuss if the core needs to change, or whether we can just monkey-patch stuff.

I'm going to gloss over the component resolution part, as I think we already have that handled in the v2 HMR code.

These steps happen when a hot reload process starts:

### Step 1
**Get the current state of the component and keep it somewhere.**
Looks like `component.$$.ctx` gives us the whole state.
However, seems like it also returns computed properties, along with handlers and bindings?
Can we work out a way to just get the props (exported or otherwise)?

### Step 2
**Get the position in the DOM where the component is mounted**
We normally add a comment marker just above the component, and store a reference to that comment node.
I think this can be achieved by the compiler using `mount_component_dev`.
`mount_component_dev` would first call `mount_component`, and then store a reference to `target` and `anchor` on `$$`.

### Step 3
**Destroy the old component**
This can be easily achieved using `component.$destroy()`.
We don't need any changes to support this.

### Step 4
**Render updated component in place of the old component**
Here, we can mount the new component with `mount_component_dev`, using the `target` and `anchor` we collected on Step 2.

### Step 5
**Apply state from old component on to the new component**
This I'm not sure at all.
We need to find a way to set the state.
Then we need to ensure that all computeds are up to date.
Will calling `$$.update()` re-run the computations?

---

I'll need your help/direction on step 1 and step 5.
I think I can do a PR for Step 2 and Step 4



Issue discussion:
Thanks, this is very useful. The `component.$$.ctx` stuff is subject to change (see e.g. #2318 and #1922), so I think we should introduce new methods that are explicitly designed for this purpose — e.g. `component.$capture_state()` and `component.$inject_state(captured_state)`. `$inject_state` would work basically the same way `$set` does currently, except that it would inject any local writable vars, not just exported ones.

Yep, `$$.update()` would recompute stuff, though again, perhaps we should create a dev-mode public method for that.

One thing I haven't wrapped my head round is if there are any subtle timing considerations around `bind:this` and lifecycle functions...
I think the proposed `$capture_state` and `$inject_state` would pretty much cover it.
Of course, along with the dev-mode public version of `$$update()`.

The only thing I forgot mention in the OP is...
Can we bring back `_debugName` from v2?
It gives us a nice name to put in the comment marker mentioned above, which we can then use in `mount_component_dev`.
Yep, no reason why not
Couple of things here are very useful/desirable in live cases too:
(some of this has cropped up in discord before)

1. being able to capture complete state 
e.g. for persistence/restoration of components, esp. as apps grow

2. dynamically changing the mount point of a component at runtime
e.g. embedded edit form that can be reused in a list view, shown in situ under item being edited

If for some reason one or both of these are not surfaced as part of the public api, then to have them unofficially available would be very much appreciated, pretty please
Any news on this issue?
Using webpack I get sapper-dev-client.js errors on `if (module.hot.status() === 'idle') {` in Firefox because `module.hot` is undefined. Can be annoying when you break on exceptions.

Edit: this is when using the Sapper template which sets hotReload: false and links to this issue.

Solution patch:
diff --git a/src/compiler/compile/render_dom/index.ts b/src/compiler/compile/render_dom/index.ts
--- a/src/compiler/compile/render_dom/index.ts
+++ b/src/compiler/compile/render_dom/index.ts
@@ -94,7 +94,7 @@ export default function dom(
 	const body = [];
 
 	const not_equal = component.component_options.immutable ? `@not_equal` : `@safe_not_equal`;
-	let dev_props_check;
+	let dev_props_check; let inject_state; let capture_state;
 
 	props.forEach(x => {
 		const variable = component.var_lookup.get(x.name);
@@ -151,6 +151,29 @@ export default function dom(
 				}`)}
 			`;
 		}
+
+		capture_state = (uses_props || writable_props.length > 0) ? deindent`
+			() => {
+				return { ${component.vars.filter(prop => prop.writable).map(prop => prop.name).join(", ")} };
+			}
+		` : deindent`
+			() => {
+				return {}
+			}
+		`;
+
+		inject_state = (uses_props || writable_props.length > 0) ? deindent`
+			${$$props} => {
+				${uses_props && component.invalidate('$$props', `$$props = @assign(@assign({}, $$props), $$new_props)`)}
+				${component.vars.filter(prop => prop.writable).map(prop => deindent`
+					if ('${prop.name}' in $$props) ${component.invalidate(prop.name, `${prop.name} = ${$$props}.${prop.name}`)};
+				`)}
+			}
+		` : deindent`
+			${$$props} => {
+				return
+			}
+		`;
 	}
 
 	// instrument assignments
@@ -430,6 +453,10 @@ export default function dom(
 
 				${set && `$$self.$set = ${set};`}
 
+				${capture_state && `$$self.$capture_state = ${capture_state};`}
+
+				${inject_state && `$$self.$inject_state = ${inject_state};`}
+
 				${injected.length && `let ${injected.join(', ')};`}
 
 				${reactive_declarations.length > 0 && deindent`


Test patch:
diff --git a/test/js/samples/capture-inject-dev-only/_config.js b/test/js/samples/capture-inject-dev-only/_config.js
new file mode 100644
--- /dev/null
+++ b/test/js/samples/capture-inject-dev-only/_config.js
@@ -0,0 +1,5 @@
+export default {
+	options: {
+		dev: false
+	}
+};
\ No newline at end of file
diff --git a/test/js/samples/capture-inject-dev-only/expected.js b/test/js/samples/capture-inject-dev-only/expected.js
new file mode 100644
--- /dev/null
+++ b/test/js/samples/capture-inject-dev-only/expected.js
@@ -0,0 +1,79 @@
+/* generated by Svelte vX.Y.Z */
+import {
+	SvelteComponent,
+	append,
+	detach,
+	element,
+	init,
+	insert,
+	listen,
+	noop,
+	safe_not_equal,
+	set_data,
+	space,
+	text
+} from "svelte/internal";
+
+function create_fragment(ctx) {
+	var p, t0, t1, input, dispose;
+
+	return {
+		c() {
+			p = element("p");
+			t0 = text(ctx.foo);
+			t1 = space();
+			input = element("input");
+			dispose = listen(input, "input", ctx.input_input_handler);
+		},
+
+		m(target, anchor) {
+			insert(target, p, anchor);
+			append(p, t0);
+			insert(target, t1, anchor);
+			insert(target, input, anchor);
+
+			input.value = ctx.foo;
+		},
+
+		p(changed, ctx) {
+			if (changed.foo) {
+				set_data(t0, ctx.foo);
+			}
+
+			if (changed.foo && (input.value !== ctx.foo)) input.value = ctx.foo;
+		},
+
+		i: noop,
+		o: noop,
+
+		d(detaching) {
+			if (detaching) {
+				detach(p);
+				detach(t1);
+				detach(input);
+			}
+
+			dispose();
+		}
+	};
+}
+
+function instance($$self, $$props, $$invalidate) {
+	let foo = "bar";
+
+	function input_input_handler() {
+		foo = this.value;
+		$$invalidate('foo', foo);
+	}
+
+	return { foo, input_input_handler };
+}
+
+class Component extends SvelteComponent {
+	constructor(options) {
+		super();
+		init(this, options, instance, create_fragment, safe_not_equal, []);
+	}
+}
+
+export default Component;
\ No newline at end of file
diff --git a/test/js/samples/capture-inject-dev-only/input.svelte b/test/js/samples/capture-inject-dev-only/input.svelte
new file mode 100644
--- /dev/null
+++ b/test/js/samples/capture-inject-dev-only/input.svelte
@@ -0,0 +1,5 @@
+<script>
+    let foo = "bar";
+</script>
+<p>{foo}</p>
+<input bind:value={foo}>
\ No newline at end of file
diff --git a/test/js/samples/debug-empty/expected.js b/test/js/samples/debug-empty/expected.js
--- a/test/js/samples/debug-empty/expected.js
+++ b/test/js/samples/debug-empty/expected.js
@@ -74,6 +74,14 @@ function instance($$self, $$props, $$invalidate) {
 		if ('name' in $$props) $$invalidate('name', name = $$props.name);
 	};
 
+	$$self.$capture_state = () => {
+		return { name };
+	};
+
+	$$self.$inject_state = $$props => {
+		if ('name' in $$props) $$invalidate('name', name = $$props.name);
+	};
+
 	return { name };
 }
 
diff --git a/test/js/samples/debug-foo-bar-baz-things/expected.js b/test/js/samples/debug-foo-bar-baz-things/expected.js
--- a/test/js/samples/debug-foo-bar-baz-things/expected.js
+++ b/test/js/samples/debug-foo-bar-baz-things/expected.js
@@ -163,6 +163,17 @@ function instance($$self, $$props, $$invalidate) {
 		if ('baz' in $$props) $$invalidate('baz', baz = $$props.baz);
 	};
 
+	$$self.$capture_state = () => {
+		return { things, foo, bar, baz };
+	};
+
+	$$self.$inject_state = $$props => {
+		if ('things' in $$props) $$invalidate('things', things = $$props.things);
+		if ('foo' in $$props) $$invalidate('foo', foo = $$props.foo);
+		if ('bar' in $$props) $$invalidate('bar', bar = $$props.bar);
+		if ('baz' in $$props) $$invalidate('baz', baz = $$props.baz);
+	};
+
 	return { things, foo, bar, baz };
 }
 
diff --git a/test/js/samples/debug-foo/expected.js b/test/js/samples/debug-foo/expected.js
--- a/test/js/samples/debug-foo/expected.js
+++ b/test/js/samples/debug-foo/expected.js
@@ -161,6 +161,15 @@ function instance($$self, $$props, $$invalidate) {
 		if ('foo' in $$props) $$invalidate('foo', foo = $$props.foo);
 	};
 
+	$$self.$capture_state = () => {
+		return { things, foo };
+	};
+
+	$$self.$inject_state = $$props => {
+		if ('things' in $$props) $$invalidate('things', things = $$props.things);
+		if ('foo' in $$props) $$invalidate('foo', foo = $$props.foo);
+	};
+
 	return { things, foo };
 }
 
diff --git a/test/js/samples/debug-hoisted/expected.js b/test/js/samples/debug-hoisted/expected.js
--- a/test/js/samples/debug-hoisted/expected.js
+++ b/test/js/samples/debug-hoisted/expected.js
@@ -43,6 +43,14 @@ let kobzol = 5;
 function instance($$self) {
 	let obj = { x: 5 };
 
+	$$self.$capture_state = () => {
+		return {}
+	};
+
+	$$self.$inject_state = $$props => {
+		return
+	};
+
 	return { obj };
 }
 
diff --git a/test/js/samples/dev-warning-missing-data-computed/expected.js b/test/js/samples/dev-warning-missing-data-computed/expected.js
--- a/test/js/samples/dev-warning-missing-data-computed/expected.js
+++ b/test/js/samples/dev-warning-missing-data-computed/expected.js
@@ -74,6 +74,15 @@ function instance($$self, $$props, $$invalidate) {
 		if ('foo' in $$props) $$invalidate('foo', foo = $$props.foo);
 	};
 
+	$$self.$capture_state = () => {
+		return { foo, bar };
+	};
+
+	$$self.$inject_state = $$props => {
+		if ('foo' in $$props) $$invalidate('foo', foo = $$props.foo);
+		if ('bar' in $$props) $$invalidate('bar', bar = $$props.bar);
+	};
+
 	$$self.$$.update = ($$dirty = { foo: 1 }) => {
 		if ($$dirty.foo) { $$invalidate('bar', bar = foo * 2); }
 	};