Repository: sveltejs/svelte (JavaScript)
Date: 2019-01-17 14:21:58+00:00
Task type: Feature

Issue:
beforeNextUpdate/afterNextUpdate
Encountered a situation where synchronous updates are really helpful, in https://terser-playground.surge.sh/ — pressing tab doesn't indent/dedent, it focuses the next textarea instead. Which is unhelpful.

It's easy to prevent the default behaviour, and relatively straightforward to implement indentation (either at the cursor's location or for an entire selected range), but the real trick comes after that: if the textarea value changes...

```js
input = new_value;
```

...then the cursor jumps to the end, whereas we want the previous cursor position/selection range to be reflected in the new one (after whatever indentation changes have been applied).

But we can't do this...

```js
input = new_value;
this.selectionStart = new_selection_start;
this.selectionEnd = new_selection_end;
```

...because `input = new_value` doesn't take effect synchronously.

The neatest way to solve this problem would probably be to have a pair of `beforeNextUpdate`/`afterNextUpdate` hooks:

```js
import { afterNextUpdate } from 'svelte';

// later...
input = new_value;
afterNextUpdate(() => {
  this.selectionStart = new_selection_start;
  this.selectionEnd = new_selection_end;
});
```


Issue discussion:
I'd say that finer-grained hooks are certainly useful, but it looks like you could achieve the same thing in this case with an existing `afterUpdate` lifecycle hook and a little bit of flagging.

I did a sample implementation to see if I could, and it worked out pretty well. It does add a little extra permanent size overhead to the core "runtime", though.
@evs-chris do you mean [this sort of thing](https://v3.svelte.technology/repl?version=3.0.0-alpha17&gist=baac12974dd1d522622557261e71483e)? I did wonder about that — it's definitely a possibility.

I do like the look of this diff though:

```diff
<script>
-  import { onDestroy, afterUpdate } from 'svelte';
+  import { onDestroy, afterNextUpdate } from 'svelte';
  
  let textarea;
  let date = new Date();
-  let selectionRange;
  
  const interval = setInterval(() => {
    date = new Date();
-    selectionRange = {
-      start: textarea.selectionStart,
-      end: textarea.selectionEnd
-    };
+    const { selectionStart, selectionEnd } = textarea;
+    afterNextUpdate(() => {
+      textarea.selectionStart = selectionStart;
+      textarea.selectionEnd = selectionEnd;
+    });
  }, 1000);
    
  onDestroy(() => clearInterval(interval));
  
-  afterUpdate(() => {
-    if (selectionRange) {
-      textarea.selectionStart = selectionRange.start;
-      textarea.selectionEnd = selectionRange.end;
-      selectionRange = null;
-    }
-  });
</script>

<style>
  textarea { width: 100% }
</style>

<p>try selecting some of the text</p>
<textarea bind:this={textarea} readonly>{date.toString()}</textarea>
```
I'm conflicted about the naming though. `afterNextUpdate` suggests symmetry with `afterUpdate`, but it's really `afterNextFlush`, since we can only call it whenever a flush happens globally — it's not component-specific. But that means introducing a new word...
Yeah, that _is_ a nice diff.

I think the concept of a flush is pretty universal, and it _does_ help convey that it's not related (specifically) to component updates. There's a monkey in my brain that thinks `afterNextFlush` is awfully long, but the more logical parts can't come up with anything that's much better. `afterFlush` sounds like an ongoing hook, and `flushThen` sounds like it will cause an immediate flush.

Solution patch:
diff --git a/index.mjs b/index.mjs
--- a/index.mjs
+++ b/index.mjs
@@ -3,5 +3,6 @@ export {
 	onDestroy,
 	beforeUpdate,
 	afterUpdate,
+	nextTick,
 	createEventDispatcher
 } from './internal';
diff --git a/src/internal/Component.js b/src/internal/Component.js
--- a/src/internal/Component.js
+++ b/src/internal/Component.js
@@ -1,4 +1,4 @@
-import { add_render_callback, flush, intros, schedule_update } from './scheduler.js';
+import { add_render_callback, flush, intros, schedule_update, dirty_components } from './scheduler.js';
 import { current_component, set_current_component } from './lifecycle.js'
 import { is_function, run, run_all, noop } from './utils.js';
 import { blankObject } from './utils.js';
@@ -46,7 +46,8 @@ function destroy(component, detach) {
 
 function make_dirty(component, key) {
 	if (!component.$$.dirty) {
-		schedule_update(component);
+		dirty_components.push(component);
+		schedule_update();
 		component.$$.dirty = {};
 	}
 	component.$$.dirty[key] = true;
diff --git a/src/internal/scheduler.js b/src/internal/scheduler.js
--- a/src/internal/scheduler.js
+++ b/src/internal/scheduler.js
@@ -1,15 +1,13 @@
 import { run_all } from './utils.js';
 
-let update_scheduled = false;
+export let dirty_components = [];
+export const intros = { enabled: false };
 
-let dirty_components = [];
+let update_scheduled = false;
 const binding_callbacks = [];
 const render_callbacks = [];
 
-export const intros = { enabled: false };
-
-export function schedule_update(component) {
-	dirty_components.push(component);
+export function schedule_update() {
 	if (!update_scheduled) {
 		update_scheduled = true;
 		queue_microtask(flush);
@@ -20,6 +18,11 @@ export function add_render_callback(fn) {
 	render_callbacks.push(fn);
 }
 
+export function nextTick(fn) {
+	add_render_callback(fn);
+	schedule_update();
+}
+
 export function add_binding_callback(fn) {
 	binding_callbacks.push(fn);
 }


Test patch:
diff --git a/test/runtime/samples/lifecycle-next-tick/_config.js b/test/runtime/samples/lifecycle-next-tick/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/lifecycle-next-tick/_config.js
@@ -0,0 +1,30 @@
+export default {
+	async test({ assert, component, target, window }) {
+		const buttons = target.querySelectorAll('button');
+		const click = new window.MouseEvent('click');
+
+		await buttons[0].dispatchEvent(click);
+		assert.deepEqual(component.snapshots, [
+			'before 0',
+			'after 1'
+		]);
+
+		await buttons[0].dispatchEvent(click);
+		assert.deepEqual(component.snapshots, [
+			'before 0',
+			'after 1',
+			'before 1',
+			'after 2'
+		]);
+
+		await buttons[1].dispatchEvent(click);
+		assert.deepEqual(component.snapshots, [
+			'before 0',
+			'after 1',
+			'before 1',
+			'after 2',
+			'before 2',
+			'after 2'
+		]);
+	}
+};
diff --git a/test/runtime/samples/lifecycle-next-tick/main.html b/test/runtime/samples/lifecycle-next-tick/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/lifecycle-next-tick/main.html
@@ -0,0 +1,24 @@
+<script>
+	import { nextTick } from 'svelte';
+
+	export let snapshots = [];
+
+	let count = 0;
+	let buttons = [];
+
+	function increment() {
+		count += 1;
+		log();
+	}
+
+	function log() {
+		snapshots.push(`before ${buttons[0].textContent}`);
+
+		nextTick(() => {
+			snapshots.push(`after ${buttons[0].textContent}`);
+		});
+	}
+</script>
+
+<button bind:this={buttons[0]} on:click={increment}>{count}</button>
+<button bind:this={buttons[1]} on:click={log}>{count}</button>
\ No newline at end of file