]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(runtime-vapor): `watchEffect`/`watch` and `onEffectCleanup` (#69)
authorRizumu Ayaka <rizumu@ayaka.moe>
Sat, 23 Dec 2023 07:17:18 +0000 (15:17 +0800)
committerGitHub <noreply@github.com>
Sat, 23 Dec 2023 07:17:18 +0000 (15:17 +0800)
20 files changed:
packages/compiler-vapor/__tests__/__snapshots__/compile.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/__snapshots__/vBind.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/__snapshots__/vHtml.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/__snapshots__/vOn.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/__snapshots__/vText.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/vBind.spec.ts
packages/compiler-vapor/__tests__/transforms/vOn.spec.ts
packages/compiler-vapor/src/generate.ts
packages/runtime-vapor/__tests__/apiWatch.spec.ts [new file with mode: 0644]
packages/runtime-vapor/__tests__/component.spec.ts
packages/runtime-vapor/src/apiWatch.ts [new file with mode: 0644]
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/directive.ts
packages/runtime-vapor/src/errorHandling.ts [new file with mode: 0644]
packages/runtime-vapor/src/index.ts
packages/runtime-vapor/src/render.ts
packages/runtime-vapor/src/scheduler.ts
packages/runtime-vapor/src/warning.ts [new file with mode: 0644]
playground/src/props.js
playground/src/scheduler.vue [new file with mode: 0644]

index 02c09554ee1b381943a692cd01eca81871acd24e..3693cd197e3c89800775a0af3219316dfed9938d 100644 (file)
@@ -1,7 +1,7 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 exports[`compile > bindings 1`] = `
-"import { template as _template, children as _children, createTextNode as _createTextNode, insert as _insert, effect as _effect, setText as _setText } from 'vue/vapor';
+"import { template as _template, children as _children, createTextNode as _createTextNode, insert as _insert, watchEffect as _watchEffect, setText as _setText } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div>count is <!>.</div>")
@@ -9,7 +9,7 @@ export function render(_ctx) {
   const { 0: [n3, { 1: [n2],}],} = _children(n0)
   const n1 = _createTextNode(_ctx.count)
   _insert(n1, n3, n2)
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n1, undefined, _ctx.count)
   })
   return n0
@@ -121,7 +121,7 @@ export function render(_ctx) {
 `;
 
 exports[`compile > directives > v-pre > self-closing v-pre 1`] = `
-"import { template as _template, children as _children, createTextNode as _createTextNode, append as _append, effect as _effect, setText as _setText, setAttr as _setAttr } from 'vue/vapor';
+"import { template as _template, children as _children, createTextNode as _createTextNode, append as _append, watchEffect as _watchEffect, setText as _setText, setAttr as _setAttr } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div><div><Comp></Comp></div>")
@@ -129,10 +129,10 @@ export function render(_ctx) {
   const { 1: [n2],} = _children(n0)
   const n1 = _createTextNode(_ctx.bar)
   _append(n2, n1)
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n1, undefined, _ctx.bar)
   })
-  _effect(() => {
+  _watchEffect(() => {
     _setAttr(n2, "id", undefined, _ctx.foo)
   })
   return n0
@@ -140,7 +140,7 @@ export function render(_ctx) {
 `;
 
 exports[`compile > directives > v-pre > should not affect siblings after it 1`] = `
-"import { template as _template, children as _children, createTextNode as _createTextNode, append as _append, effect as _effect, setText as _setText, setAttr as _setAttr } from 'vue/vapor';
+"import { template as _template, children as _children, createTextNode as _createTextNode, append as _append, watchEffect as _watchEffect, setText as _setText, setAttr as _setAttr } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div :id=\\"foo\\"><Comp></Comp>{{ bar }}</div><div><Comp></Comp></div>")
@@ -148,10 +148,10 @@ export function render(_ctx) {
   const { 1: [n2],} = _children(n0)
   const n1 = _createTextNode(_ctx.bar)
   _append(n2, n1)
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n1, undefined, _ctx.bar)
   })
-  _effect(() => {
+  _watchEffect(() => {
     _setAttr(n2, "id", undefined, _ctx.foo)
   })
   return n0
@@ -159,7 +159,7 @@ export function render(_ctx) {
 `;
 
 exports[`compile > dynamic root 1`] = `
-"import { fragment as _fragment, createTextNode as _createTextNode, append as _append, effect as _effect, setText as _setText } from 'vue/vapor';
+"import { fragment as _fragment, createTextNode as _createTextNode, append as _append, watchEffect as _watchEffect, setText as _setText } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _fragment()
@@ -168,10 +168,10 @@ export function render(_ctx) {
   const n1 = _createTextNode(1)
   const n2 = _createTextNode(2)
   _append(n0, n1, n2)
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n1, undefined, 1)
   })
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n2, undefined, 2)
   })
   return n0
@@ -179,7 +179,7 @@ export function render(_ctx) {
 `;
 
 exports[`compile > dynamic root nodes and interpolation 1`] = `
-"import { template as _template, children as _children, createTextNode as _createTextNode, prepend as _prepend, insert as _insert, append as _append, on as _on, effect as _effect, setText as _setText, setAttr as _setAttr } from 'vue/vapor';
+"import { template as _template, children as _children, createTextNode as _createTextNode, prepend as _prepend, insert as _insert, append as _append, on as _on, watchEffect as _watchEffect, setText as _setText, setAttr as _setAttr } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<button>foo<!>foo</button>")
@@ -192,7 +192,7 @@ export function render(_ctx) {
   _insert(n2, n4, n5)
   _append(n4, n3)
   _on(n4, "click", (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n1, undefined, _ctx.count)
     _setText(n2, undefined, _ctx.count)
     _setText(n3, undefined, _ctx.count)
@@ -207,7 +207,7 @@ exports[`compile > expression parsing > interpolation 1`] = `
   const t0 = _fragment()
 
   const n0 = t0()
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n0, undefined, a + b.value)
   })
   return n0
@@ -219,7 +219,7 @@ exports[`compile > expression parsing > v-bind 1`] = `
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setAttr(n1, key.value+1, undefined, _unref(foo)[key.value+1]())
   })
   return n0
@@ -237,7 +237,7 @@ export function render(_ctx) {
 `;
 
 exports[`compile > static + dynamic root 1`] = `
-"import { template as _template, children as _children, createTextNode as _createTextNode, prepend as _prepend, insert as _insert, append as _append, effect as _effect, setText as _setText } from 'vue/vapor';
+"import { template as _template, children as _children, createTextNode as _createTextNode, prepend as _prepend, insert as _insert, append as _append, watchEffect as _watchEffect, setText as _setText } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("3<!>6<!>9")
@@ -255,28 +255,28 @@ export function render(_ctx) {
   _insert([n3, n4], n0, n9)
   _insert([n5, n6], n0, n10)
   _append(n0, n7, n8)
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n1, undefined, 1)
   })
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n2, undefined, 2)
   })
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n3, undefined, 4)
   })
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n4, undefined, 5)
   })
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n5, undefined, 7)
   })
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n6, undefined, 8)
   })
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n7, undefined, 'A')
   })
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n8, undefined, 'B')
   })
   return n0
index 910d019cd663414ed8aa7b8038434bd4768bbfd0..63936f19e956ff0e2c99f09140931769b547c2d7 100644 (file)
@@ -1,13 +1,13 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 exports[`compiler v-bind > .camel modifier 1`] = `
-"import { template as _template, children as _children, effect as _effect, setAttr as _setAttr } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, setAttr as _setAttr } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setAttr(n1, "fooBar", undefined, _ctx.id)
   })
   return n0
@@ -21,7 +21,7 @@ export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setAttr(n1, _camelize(_ctx.foo), undefined, _ctx.id)
   })
   return n0
@@ -29,13 +29,13 @@ export function render(_ctx) {
 `;
 
 exports[`compiler v-bind > .camel modifier w/ no expression 1`] = `
-"import { template as _template, children as _children, effect as _effect, setAttr as _setAttr } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, setAttr as _setAttr } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setAttr(n1, "fooBar", undefined, _ctx.fooBar)
   })
   return n0
@@ -43,13 +43,13 @@ export function render(_ctx) {
 `;
 
 exports[`compiler v-bind > basic 1`] = `
-"import { template as _template, children as _children, effect as _effect, setAttr as _setAttr } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, setAttr as _setAttr } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setAttr(n1, "id", undefined, _ctx.id)
   })
   return n0
@@ -57,13 +57,13 @@ export function render(_ctx) {
 `;
 
 exports[`compiler v-bind > dynamic arg 1`] = `
-"import { template as _template, children as _children, effect as _effect, setAttr as _setAttr } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, setAttr as _setAttr } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setAttr(n1, _ctx.id, undefined, _ctx.id)
   })
   return n0
@@ -71,13 +71,13 @@ export function render(_ctx) {
 `;
 
 exports[`compiler v-bind > no expression (shorthand) 1`] = `
-"import { template as _template, children as _children, effect as _effect, setAttr as _setAttr } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, setAttr as _setAttr } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setAttr(n1, "camel-case", undefined, _ctx.camelCase)
   })
   return n0
@@ -85,13 +85,13 @@ export function render(_ctx) {
 `;
 
 exports[`compiler v-bind > no expression 1`] = `
-"import { template as _template, children as _children, effect as _effect, setAttr as _setAttr } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, setAttr as _setAttr } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setAttr(n1, "id", undefined, _ctx.id)
   })
   return n0
index d3abf8903d7c1530590f42f1fddddec751d08037..e1da7d1570b5490e8123020c6f7f7bcb07550067 100644 (file)
@@ -1,13 +1,13 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 exports[`v-html > should convert v-html to innerHTML 1`] = `
-"import { template as _template, children as _children, effect as _effect, setHtml as _setHtml } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, setHtml as _setHtml } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setHtml(n1, undefined, _ctx.code)
   })
   return n0
@@ -15,13 +15,13 @@ export function render(_ctx) {
 `;
 
 exports[`v-html > should raise error and ignore children when v-html is present 1`] = `
-"import { template as _template, children as _children, effect as _effect, setHtml as _setHtml } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, setHtml as _setHtml } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setHtml(n1, undefined, _ctx.test)
   })
   return n0
index b1c8449cc9eefce866d37dc693fc14cfec789a66..c1f85b96a2165d7c8e27ebff1b7b1c33508fa2d3 100644 (file)
@@ -13,13 +13,13 @@ export function render(_ctx) {
 `;
 
 exports[`v-on > dynamic arg 1`] = `
-"import { template as _template, children as _children, effect as _effect, on as _on } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, on as _on } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _on(n1, _ctx.event, (...args) => (_ctx.handler && _ctx.handler(...args)))
   })
   return n0
@@ -109,13 +109,13 @@ export function render(_ctx) {
 `;
 
 exports[`v-on > should transform click.middle 2`] = `
-"import { template as _template, children as _children, effect as _effect, on as _on, withModifiers as _withModifiers } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, on as _on, withModifiers as _withModifiers } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _on(n1, (_ctx.event) === "click" ? "mouseup" : (_ctx.event), _withModifiers((...args) => (_ctx.test && _ctx.test(...args)), ["middle"]))
   })
   return n0
@@ -135,13 +135,13 @@ export function render(_ctx) {
 `;
 
 exports[`v-on > should transform click.right 2`] = `
-"import { template as _template, children as _children, effect as _effect, on as _on, withKeys as _withKeys, withModifiers as _withModifiers } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, on as _on, withKeys as _withKeys, withModifiers as _withModifiers } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _on(n1, (_ctx.event) === "click" ? "contextmenu" : (_ctx.event), _withKeys(_withModifiers((...args) => (_ctx.test && _ctx.test(...args)), ["right"]), ["right"]))
   })
   return n0
index d18e398b11a0795920a01cbc5752a0978a56629d..8beed48c7747b6fce228feff2ba119b421d3508e 100644 (file)
@@ -1,13 +1,13 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 exports[`v-text > should convert v-text to textContent 1`] = `
-"import { template as _template, children as _children, effect as _effect, setText as _setText } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, setText as _setText } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n1, undefined, _ctx.str)
   })
   return n0
@@ -15,13 +15,13 @@ export function render(_ctx) {
 `;
 
 exports[`v-text > should raise error and ignore children when v-text is present 1`] = `
-"import { template as _template, children as _children, effect as _effect, setText as _setText } from 'vue/vapor';
+"import { template as _template, children as _children, watchEffect as _watchEffect, setText as _setText } from 'vue/vapor';
 
 export function render(_ctx) {
   const t0 = _template("<div></div>")
   const n0 = t0()
   const { 0: [n1],} = _children(n0)
-  _effect(() => {
+  _watchEffect(() => {
     _setText(n1, undefined, _ctx.test)
   })
   return n0
index c81f2e51a48336e1c57ea03efea8a29edb22337a..31a1a8d0f272f599b867672480143331fc1ad360 100644 (file)
@@ -210,7 +210,7 @@ describe('compiler v-bind', () => {
     })
 
     expect(code).matchSnapshot()
-    expect(code).contains('effect')
+    expect(code).contains('watchEffect')
     expect(code).contains('_setAttr(n1, "fooBar", undefined, _ctx.fooBar)')
   })
 
@@ -230,7 +230,7 @@ describe('compiler v-bind', () => {
     })
 
     expect(code).matchSnapshot()
-    expect(code).contains('effect')
+    expect(code).contains('watchEffect')
     expect(code).contains(
       `_setAttr(n1, _camelize(_ctx.foo), undefined, _ctx.id)`,
     )
index c731f30d0a24970ec4b6936174b5d01a77747393..d9076607face6236652be646c9e96a5c84788fee 100644 (file)
@@ -102,7 +102,7 @@ describe('v-on', () => {
     const { code, ir } = compileWithVOn(`<div v-on:[event]="handler"/>`)
 
     expect(ir.vaporHelpers).contains('on')
-    expect(ir.vaporHelpers).contains('effect')
+    expect(ir.vaporHelpers).contains('watchEffect')
     expect(ir.helpers.size).toBe(0)
     expect(ir.operation).toEqual([])
 
index b79ac2901925817bf2e978e81ff08edcde142c80..339b05d660c40fd6f486592002f681f1b6ed768e 100644 (file)
@@ -293,7 +293,7 @@ export function generate(
       }
 
       for (const { operations } of ir.effect) {
-        pushNewline(`${vaporHelper('effect')}(() => {`)
+        pushNewline(`${vaporHelper('watchEffect')}(() => {`)
         withIndent(() => {
           for (const operation of operations) {
             genOperation(operation, ctx)
diff --git a/packages/runtime-vapor/__tests__/apiWatch.spec.ts b/packages/runtime-vapor/__tests__/apiWatch.spec.ts
new file mode 100644 (file)
index 0000000..02f88de
--- /dev/null
@@ -0,0 +1,163 @@
+import { EffectScope, Ref, ref } from '@vue/reactivity'
+import {
+  onEffectCleanup,
+  watchEffect,
+  watchPostEffect,
+  watchSyncEffect,
+} from '../src/apiWatch'
+import { nextTick } from '../src/scheduler'
+import { defineComponent } from 'vue'
+import { render } from '../src/render'
+import { template } from '../src/template'
+
+let host: HTMLElement
+
+const initHost = () => {
+  host = document.createElement('div')
+  host.setAttribute('id', 'host')
+  document.body.appendChild(host)
+}
+beforeEach(() => {
+  initHost()
+})
+afterEach(() => {
+  host.remove()
+})
+
+describe('watchEffect and onEffectCleanup', () => {
+  test('basic', async () => {
+    let dummy = 0
+    let source: Ref<number>
+    const scope = new EffectScope()
+
+    scope.run(() => {
+      source = ref(0)
+      watchEffect((onCleanup) => {
+        source.value
+
+        onCleanup(() => (dummy += 2))
+        onEffectCleanup(() => (dummy += 3))
+        onEffectCleanup(() => (dummy += 5))
+      })
+    })
+    await nextTick()
+    expect(dummy).toBe(0)
+
+    scope.run(() => {
+      source.value++
+    })
+    await nextTick()
+    expect(dummy).toBe(10)
+
+    scope.run(() => {
+      source.value++
+    })
+    await nextTick()
+    expect(dummy).toBe(20)
+
+    scope.stop()
+    await nextTick()
+    expect(dummy).toBe(30)
+  })
+
+  test('nested call to watchEffect', async () => {
+    let dummy = 0
+    let source: Ref<number>
+    let double: Ref<number>
+    const scope = new EffectScope()
+
+    scope.run(() => {
+      source = ref(0)
+      double = ref(0)
+      watchEffect(() => {
+        double.value = source.value * 2
+        onEffectCleanup(() => (dummy += 2))
+      })
+      watchSyncEffect(() => {
+        double.value
+        onEffectCleanup(() => (dummy += 3))
+      })
+    })
+    await nextTick()
+    expect(dummy).toBe(0)
+
+    scope.run(() => source.value++)
+    await nextTick()
+    expect(dummy).toBe(5)
+
+    scope.run(() => source.value++)
+    await nextTick()
+    expect(dummy).toBe(10)
+
+    scope.stop()
+    await nextTick()
+    expect(dummy).toBe(15)
+  })
+
+  test('scheduling order', async () => {
+    const calls: string[] = []
+
+    const demo = defineComponent({
+      setup() {
+        const source = ref(0)
+        const change = () => source.value++
+
+        watchPostEffect(() => {
+          const current = source.value
+          calls.push(`post ${current}`)
+          onEffectCleanup(() => calls.push(`post cleanup ${current}`))
+        })
+        watchEffect(() => {
+          const current = source.value
+          calls.push(`pre ${current}`)
+          onEffectCleanup(() => calls.push(`pre cleanup ${current}`))
+        })
+        watchSyncEffect(() => {
+          const current = source.value
+          calls.push(`sync ${current}`)
+          onEffectCleanup(() => calls.push(`sync cleanup ${current}`))
+        })
+        const __returned__ = { source, change }
+        Object.defineProperty(__returned__, '__isScriptSetup', {
+          enumerable: false,
+          value: true,
+        })
+        return __returned__
+      },
+    })
+
+    demo.render = (_ctx: any) => {
+      const t0 = template('<div></div>')
+      watchEffect(() => {
+        const current = _ctx.source
+        calls.push(`render ${current}`)
+        onEffectCleanup(() => calls.push(`render cleanup ${current}`))
+      })
+      return t0()
+    }
+
+    const instance = render(demo as any, {}, '#host')
+    const { change } = instance.proxy as any
+
+    expect(calls).toEqual(['pre 0', 'sync 0', 'render 0'])
+    calls.length = 0
+
+    await nextTick()
+    expect(calls).toEqual(['post 0'])
+    calls.length = 0
+
+    change()
+    expect(calls).toEqual(['sync cleanup 0', 'sync 1'])
+    calls.length = 0
+
+    await nextTick()
+    expect(calls).toEqual([
+      'pre cleanup 0',
+      'pre 1',
+      'render cleanup 0',
+      'render 1',
+      'post cleanup 0',
+      'post 1',
+    ])
+  })
+})
index 549402a00c2cf7605c552da4e53ba3b0a824f5ae..5699dd5940b024f2d301ecb53666132e39c3afc7 100644 (file)
@@ -1,11 +1,11 @@
 import {
   template,
   children,
-  effect,
   setText,
   render,
   ref,
   unmountComponent,
+  watchEffect,
 } from '../src'
 import { afterEach, beforeEach, describe, expect } from 'vitest'
 import { defineComponent } from '@vue/runtime-core'
@@ -33,7 +33,7 @@ describe('component', () => {
         const {
           0: [n1],
         } = children(n0)
-        effect(() => {
+        watchEffect(() => {
           setText(n1, void 0, count.value)
         })
         return n0
diff --git a/packages/runtime-vapor/src/apiWatch.ts b/packages/runtime-vapor/src/apiWatch.ts
new file mode 100644 (file)
index 0000000..ee88648
--- /dev/null
@@ -0,0 +1,439 @@
+import {
+  ComputedRef,
+  Ref,
+  isReactive,
+  isRef,
+  ReactiveEffect,
+  EffectScheduler,
+  DebuggerOptions,
+  getCurrentScope,
+  ReactiveFlags,
+} from '@vue/reactivity'
+import {
+  EMPTY_OBJ,
+  NOOP,
+  extend,
+  hasChanged,
+  isArray,
+  isFunction,
+  isMap,
+  isObject,
+  isPlainObject,
+  isSet,
+  remove,
+} from '@vue/shared'
+import { currentInstance } from './component'
+import {
+  type Scheduler,
+  getVaporSchedulerByFlushMode,
+  vaporPostScheduler,
+  vaporSyncScheduler,
+  SchedulerJob,
+} from './scheduler'
+import {
+  VaporErrorCodes,
+  callWithAsyncErrorHandling,
+  callWithErrorHandling,
+} from './errorHandling'
+import { warn } from './warning'
+
+export type WatchEffect = (onCleanup: OnCleanup) => void
+
+export type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)
+
+export type WatchCallback<V = any, OV = any> = (
+  value: V,
+  oldValue: OV,
+  onCleanup: OnCleanup,
+) => any
+
+type MapSources<T, Immediate> = {
+  [K in keyof T]: T[K] extends WatchSource<infer V>
+    ? Immediate extends true
+      ? V | undefined
+      : V
+    : T[K] extends object
+      ? Immediate extends true
+        ? T[K] | undefined
+        : T[K]
+      : never
+}
+
+type OnCleanup = (cleanupFn: () => void) => void
+
+export interface WatchOptionsBase extends DebuggerOptions {
+  flush?: 'pre' | 'post' | 'sync'
+}
+
+export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
+  immediate?: Immediate
+  deep?: boolean
+  once?: boolean
+}
+
+export type WatchStopHandle = () => void
+
+// Simple effect.
+export function watchEffect(
+  effect: WatchEffect,
+  options: WatchOptionsBase = EMPTY_OBJ,
+): WatchStopHandle {
+  const { flush } = options
+  return doWatch(effect, null, getVaporSchedulerByFlushMode(flush), options)
+}
+
+export function watchPostEffect(
+  effect: WatchEffect,
+  options?: DebuggerOptions,
+) {
+  return doWatch(
+    effect,
+    null,
+    vaporPostScheduler,
+    __DEV__ ? extend({}, options as any, { flush: 'post' }) : { flush: 'post' },
+  )
+}
+
+export function watchSyncEffect(
+  effect: WatchEffect,
+  options?: DebuggerOptions,
+) {
+  return doWatch(
+    effect,
+    null,
+    vaporSyncScheduler,
+    __DEV__ ? extend({}, options as any, { flush: 'sync' }) : { flush: 'sync' },
+  )
+}
+
+// initial value for watchers to trigger on undefined initial values
+const INITIAL_WATCHER_VALUE = {}
+
+type MultiWatchSources = (WatchSource<unknown> | object)[]
+
+// overload: array of multiple sources + cb
+export function watch<
+  T extends MultiWatchSources,
+  Immediate extends Readonly<boolean> = false,
+>(
+  sources: [...T],
+  cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
+  options?: WatchOptions<Immediate>,
+): WatchStopHandle
+
+// overload: multiple sources w/ `as const`
+// watch([foo, bar] as const, () => {})
+// somehow [...T] breaks when the type is readonly
+export function watch<
+  T extends Readonly<MultiWatchSources>,
+  Immediate extends Readonly<boolean> = false,
+>(
+  source: T,
+  cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
+  options?: WatchOptions<Immediate>,
+): WatchStopHandle
+
+// overload: single source + cb
+export function watch<T, Immediate extends Readonly<boolean> = false>(
+  source: WatchSource<T>,
+  cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
+  options?: WatchOptions<Immediate>,
+): WatchStopHandle
+
+// overload: watching reactive object w/ cb
+export function watch<
+  T extends object,
+  Immediate extends Readonly<boolean> = false,
+>(
+  source: T,
+  cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
+  options?: WatchOptions<Immediate>,
+): WatchStopHandle
+
+// implementation
+export function watch<T = any, Immediate extends Readonly<boolean> = false>(
+  source: T | WatchSource<T>,
+  cb: any,
+  options: WatchOptions<Immediate> = EMPTY_OBJ,
+): WatchStopHandle {
+  if (__DEV__ && !isFunction(cb)) {
+    warn(
+      `\`watch(fn, options?)\` signature has been moved to a separate API. ` +
+        `Use \`watchEffect(fn, options?)\` instead. \`watch\` now only ` +
+        `supports \`watch(source, cb, options?) signature.`,
+    )
+  }
+  const { flush } = options
+  return doWatch(
+    source as any,
+    cb,
+    getVaporSchedulerByFlushMode(flush),
+    options,
+  )
+}
+
+const cleanupMap: WeakMap<ReactiveEffect, (() => void)[]> = new WeakMap()
+let activeEffect: ReactiveEffect | undefined = undefined
+
+// TODO: extract it to the reactivity package
+export function onEffectCleanup(cleanupFn: () => void) {
+  if (activeEffect) {
+    const cleanups =
+      cleanupMap.get(activeEffect) ||
+      cleanupMap.set(activeEffect, []).get(activeEffect)!
+    cleanups.push(cleanupFn)
+  }
+}
+
+export interface doWatchOptions<Immediate = boolean> extends DebuggerOptions {
+  immediate?: Immediate
+  deep?: boolean
+  once?: boolean
+}
+
+function doWatch(
+  source: WatchSource | WatchSource[] | WatchEffect | object,
+  cb: WatchCallback | null,
+  scheduler: Scheduler,
+  { immediate, deep, once, onTrack, onTrigger }: doWatchOptions = EMPTY_OBJ,
+): WatchStopHandle {
+  if (cb && once) {
+    const _cb = cb
+    cb = (...args) => {
+      _cb(...args)
+      unwatch()
+    }
+  }
+
+  if (__DEV__ && !cb) {
+    if (immediate !== undefined) {
+      warn(
+        `watch() "immediate" option is only respected when using the ` +
+          `watch(source, callback, options?) signature.`,
+      )
+    }
+    if (deep !== undefined) {
+      warn(
+        `watch() "deep" option is only respected when using the ` +
+          `watch(source, callback, options?) signature.`,
+      )
+    }
+    if (once !== undefined) {
+      warn(
+        `watch() "once" option is only respected when using the ` +
+          `watch(source, callback, options?) signature.`,
+      )
+    }
+  }
+
+  const warnInvalidSource = (s: unknown) => {
+    warn(
+      `Invalid watch source: `,
+      s,
+      `A watch source can only be a getter/effect function, a ref, ` +
+        `a reactive object, or an array of these types.`,
+    )
+  }
+
+  const instance =
+    getCurrentScope() === currentInstance?.scope ? currentInstance : null
+  // const instance = currentInstance
+  let getter: () => any
+  let forceTrigger = false
+  let isMultiSource = false
+
+  if (isRef(source)) {
+    getter = () => source.value
+  } else if (isReactive(source)) {
+    getter = () => source
+    deep = true
+  } else if (isArray(source)) {
+    getter = () =>
+      source.map((s) => {
+        if (isRef(s)) {
+          return s.value
+        } else if (isReactive(s)) {
+          return traverse(s)
+        } else if (isFunction(s)) {
+          return callWithErrorHandling(
+            s,
+            instance,
+            VaporErrorCodes.WATCH_GETTER,
+          )
+        } else {
+          __DEV__ && warnInvalidSource(s)
+        }
+      })
+  } else if (isFunction(source)) {
+    if (cb) {
+      // getter with cb
+      getter = () =>
+        callWithErrorHandling(source, instance, VaporErrorCodes.WATCH_GETTER)
+    } else {
+      // no cb -> simple effect
+      getter = () => {
+        if (instance && instance.isUnmounted) {
+          return
+        }
+        if (cleanup) {
+          cleanup()
+        }
+        const currentEffect = activeEffect
+        activeEffect = effect
+        try {
+          return callWithAsyncErrorHandling(
+            source,
+            instance,
+            VaporErrorCodes.WATCH_CALLBACK,
+            [onEffectCleanup],
+          )
+        } finally {
+          activeEffect = currentEffect
+        }
+      }
+    }
+  } else {
+    getter = NOOP
+    __DEV__ && warnInvalidSource(source)
+  }
+
+  if (cb && deep) {
+    const baseGetter = getter
+    getter = () => traverse(baseGetter())
+  }
+
+  // TODO: ssr
+  // if (__SSR__ && isInSSRComponentSetup) {
+  // }
+
+  let oldValue: any = isMultiSource
+    ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
+    : INITIAL_WATCHER_VALUE
+  const job: SchedulerJob = () => {
+    if (!effect.active || !effect.dirty) {
+      return
+    }
+    if (cb) {
+      // watch(source, cb)
+      const newValue = effect.run()
+      if (
+        deep ||
+        forceTrigger ||
+        (isMultiSource
+          ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
+          : hasChanged(newValue, oldValue))
+      ) {
+        // cleanup before running cb again
+        if (cleanup) {
+          cleanup()
+        }
+        const currentEffect = activeEffect
+        activeEffect = effect
+        try {
+          callWithAsyncErrorHandling(
+            cb,
+            instance,
+            VaporErrorCodes.WATCH_CALLBACK,
+            [
+              newValue,
+              // pass undefined as the old value when it's changed for the first time
+              oldValue === INITIAL_WATCHER_VALUE
+                ? undefined
+                : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
+                  ? []
+                  : oldValue,
+              onEffectCleanup,
+            ],
+          )
+          oldValue = newValue
+        } finally {
+          activeEffect = currentEffect
+        }
+      }
+    } else {
+      // watchEffect
+      effect.run()
+    }
+  }
+
+  // important: mark the job as a watcher callback so that scheduler knows
+  // it is allowed to self-trigger (#1727)
+  job.allowRecurse = !!cb
+
+  let effectScheduler: EffectScheduler = () =>
+    scheduler({
+      effect,
+      job,
+      instance: instance,
+      isInit: false,
+    })
+
+  const effect = new ReactiveEffect(getter, NOOP, effectScheduler)
+
+  const cleanup = (effect.onStop = () => {
+    const cleanups = cleanupMap.get(effect)
+    if (cleanups) {
+      cleanups.forEach((cleanup) => cleanup())
+      cleanupMap.delete(effect)
+    }
+  })
+
+  const unwatch = () => {
+    effect.stop()
+    if (instance && instance.scope) {
+      remove(instance.scope.effects!, effect)
+    }
+  }
+
+  if (__DEV__) {
+    effect.onTrack = onTrack
+    effect.onTrigger = onTrigger
+  }
+
+  // initial run
+  if (cb) {
+    if (immediate) {
+      job()
+    } else {
+      oldValue = effect.run()
+    }
+  } else {
+    scheduler({
+      effect,
+      job,
+      instance: instance,
+      isInit: true,
+    })
+  }
+
+  // TODO: ssr
+  // if (__SSR__ && ssrCleanup) ssrCleanup.push(unwatch)
+  return unwatch
+}
+
+export function traverse(value: unknown, seen?: Set<unknown>) {
+  if (!isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
+    return value
+  }
+  seen = seen || new Set()
+  if (seen.has(value)) {
+    return value
+  }
+  seen.add(value)
+  if (isRef(value)) {
+    traverse(value.value, seen)
+  } else if (isArray(value)) {
+    for (let i = 0; i < value.length; i++) {
+      traverse(value[i], seen)
+    }
+  } else if (isSet(value) || isMap(value)) {
+    value.forEach((v: any) => {
+      traverse(v, seen)
+    })
+  } else if (isPlainObject(value)) {
+    for (const key in value) {
+      traverse(value[key], seen)
+    }
+  }
+  return value
+}
index 23b5f0770e38ee3191447cbe1f580b9570c0fe2c..63e1f25285358974e0c426d73fb69f36e9904b1d 100644 (file)
@@ -35,6 +35,8 @@ export interface ComponentInternalInstance {
   component: FunctionalComponent | ObjectComponent
   propsOptions: NormalizedPropsOptions
 
+  parent: ComponentInternalInstance | null
+
   // TODO: type
   proxy: Data | null
 
@@ -50,7 +52,7 @@ export interface ComponentInternalInstance {
   get isUnmounted(): boolean
   isUnmountedRef: Ref<boolean>
   isMountedRef: Ref<boolean>
-  // TODO: registory of provides, appContext, lifecycles, ...
+  // TODO: registory of provides, lifecycles, ...
   /**
    * @internal
    */
@@ -136,6 +138,9 @@ export const createComponentInstance = (
     scope: new EffectScope(true /* detached */)!,
     component,
 
+    // TODO: registory of parent
+    parent: null,
+
     // resolved props and emits options
     propsOptions: normalizePropsOptions(component),
     // emitsOptions: normalizeEmitsOptions(type, appContext), // TODO:
index 3906eb7234696052c74a0a4db3b17bc113d30388..0951cbeebcccf42ddeea48edafe8a30378bd87d6 100644 (file)
@@ -1,6 +1,6 @@
 import { isFunction } from '@vue/shared'
 import { currentInstance, type ComponentInternalInstance } from './component'
-import { effect } from './scheduler'
+import { watchEffect } from './apiWatch'
 
 export type DirectiveModifiers<M extends string = string> = Record<M, boolean>
 
@@ -95,7 +95,7 @@ export function withDirectives<T extends Node>(
 
     callDirectiveHook(node, binding, 'created')
 
-    effect(() => {
+    watchEffect(() => {
       if (!instance.isMountedRef.value) return
       callDirectiveHook(node, binding, 'updated')
     })
diff --git a/packages/runtime-vapor/src/errorHandling.ts b/packages/runtime-vapor/src/errorHandling.ts
new file mode 100644 (file)
index 0000000..7c00565
--- /dev/null
@@ -0,0 +1,166 @@
+// These codes originate from a file of the same name in runtime-core,
+// duplicated during Vapor's early development to ensure its independence.
+// The ultimate aim is to uncouple this replicated code and
+// facilitate its shared use between two runtimes.
+
+import { VaporLifecycleHooks } from './apiLifecycle'
+import { type ComponentInternalInstance } from './component'
+import { isFunction, isPromise } from '@vue/shared'
+import { warn } from './warning'
+
+// contexts where user provided function may be executed, in addition to
+// lifecycle hooks.
+export enum VaporErrorCodes {
+  SETUP_FUNCTION,
+  RENDER_FUNCTION,
+  WATCH_GETTER,
+  WATCH_CALLBACK,
+  WATCH_CLEANUP,
+  NATIVE_EVENT_HANDLER,
+  COMPONENT_EVENT_HANDLER,
+  VNODE_HOOK,
+  DIRECTIVE_HOOK,
+  TRANSITION_HOOK,
+  APP_ERROR_HANDLER,
+  APP_WARN_HANDLER,
+  FUNCTION_REF,
+  ASYNC_COMPONENT_LOADER,
+  SCHEDULER,
+}
+
+export const ErrorTypeStrings: Record<
+  VaporLifecycleHooks | VaporErrorCodes,
+  string
+> = {
+  // [VaporLifecycleHooks.SERVER_PREFETCH]: 'serverPrefetch hook',
+  [VaporLifecycleHooks.BEFORE_CREATE]: 'beforeCreate hook',
+  [VaporLifecycleHooks.CREATED]: 'created hook',
+  [VaporLifecycleHooks.BEFORE_MOUNT]: 'beforeMount hook',
+  [VaporLifecycleHooks.MOUNTED]: 'mounted hook',
+  [VaporLifecycleHooks.BEFORE_UPDATE]: 'beforeUpdate hook',
+  [VaporLifecycleHooks.UPDATED]: 'updated',
+  [VaporLifecycleHooks.BEFORE_UNMOUNT]: 'beforeUnmount hook',
+  [VaporLifecycleHooks.UNMOUNTED]: 'unmounted hook',
+  [VaporLifecycleHooks.ACTIVATED]: 'activated hook',
+  [VaporLifecycleHooks.DEACTIVATED]: 'deactivated hook',
+  [VaporLifecycleHooks.ERROR_CAPTURED]: 'errorCaptured hook',
+  [VaporLifecycleHooks.RENDER_TRACKED]: 'renderTracked hook',
+  [VaporLifecycleHooks.RENDER_TRIGGERED]: 'renderTriggered hook',
+  [VaporErrorCodes.SETUP_FUNCTION]: 'setup function',
+  [VaporErrorCodes.RENDER_FUNCTION]: 'render function',
+  [VaporErrorCodes.WATCH_GETTER]: 'watcher getter',
+  [VaporErrorCodes.WATCH_CALLBACK]: 'watcher callback',
+  [VaporErrorCodes.WATCH_CLEANUP]: 'watcher cleanup function',
+  [VaporErrorCodes.NATIVE_EVENT_HANDLER]: 'native event handler',
+  [VaporErrorCodes.COMPONENT_EVENT_HANDLER]: 'component event handler',
+  [VaporErrorCodes.VNODE_HOOK]: 'vnode hook',
+  [VaporErrorCodes.DIRECTIVE_HOOK]: 'directive hook',
+  [VaporErrorCodes.TRANSITION_HOOK]: 'transition hook',
+  [VaporErrorCodes.APP_ERROR_HANDLER]: 'app errorHandler',
+  [VaporErrorCodes.APP_WARN_HANDLER]: 'app warnHandler',
+  [VaporErrorCodes.FUNCTION_REF]: 'ref function',
+  [VaporErrorCodes.ASYNC_COMPONENT_LOADER]: 'async component loader',
+  [VaporErrorCodes.SCHEDULER]:
+    'scheduler flush. This is likely a Vue internals bug. ' +
+    'Please open an issue at https://new-issue.vuejs.org/?repo=vuejs/core',
+}
+
+export type ErrorTypes = VaporLifecycleHooks | VaporErrorCodes
+
+export function callWithErrorHandling(
+  fn: Function,
+  instance: ComponentInternalInstance | null,
+  type: ErrorTypes,
+  args?: unknown[],
+) {
+  let res
+  try {
+    res = args ? fn(...args) : fn()
+  } catch (err) {
+    handleError(err, instance, type)
+  }
+  return res
+}
+
+export function callWithAsyncErrorHandling(
+  fn: Function | Function[],
+  instance: ComponentInternalInstance | null,
+  type: ErrorTypes,
+  args?: unknown[],
+): any[] {
+  if (isFunction(fn)) {
+    const res = callWithErrorHandling(fn, instance, type, args)
+    if (res && isPromise(res)) {
+      res.catch((err) => {
+        handleError(err, instance, type)
+      })
+    }
+    return res
+  }
+
+  const values = []
+  for (let i = 0; i < fn.length; i++) {
+    values.push(callWithAsyncErrorHandling(fn[i], instance, type, args))
+  }
+  return values
+}
+
+export function handleError(
+  err: unknown,
+  instance: ComponentInternalInstance | null,
+  type: ErrorTypes,
+  throwInDev = true,
+) {
+  if (instance) {
+    let cur = instance.parent
+    // the exposed instance is the render proxy to keep it consistent with 2.x
+    const exposedInstance = ('proxy' in instance && instance.proxy) || null
+    // in production the hook receives only the error code
+    const errorInfo = __DEV__
+      ? ErrorTypeStrings[type]
+      : `https://vuejs.org/errors/#runtime-${type}`
+    while (cur) {
+      const errorCapturedHooks = 'ec' in cur ? cur.ec : null
+      if (errorCapturedHooks) {
+        for (let i = 0; i < errorCapturedHooks.length; i++) {
+          if (
+            errorCapturedHooks[i](err, exposedInstance, errorInfo) === false
+          ) {
+            return
+          }
+        }
+      }
+      cur = cur.parent
+    }
+
+    // TODO: need appContext interface
+    // app-level handling
+    // const appErrorHandler = instance.appContext?.config.errorHandler
+    // if (appErrorHandler) {
+    //   callWithErrorHandling(
+    //     appErrorHandler,
+    //     null,
+    //     ErrorCodes.APP_ERROR_HANDLER,
+    //     [err, exposedInstance, errorInfo],
+    //   )
+    //   return
+    // }
+  }
+  logError(err, type, throwInDev)
+}
+
+function logError(err: unknown, type: ErrorTypes, throwInDev = true) {
+  if (__DEV__) {
+    const info = ErrorTypeStrings[type]
+    warn(`Unhandled error${info ? ` during execution of ${info}` : ``}`)
+    // crash in dev by default so it's more noticeable
+    if (throwInDev) {
+      throw err
+    } else if (!__TEST__) {
+      console.error(err)
+    }
+  } else {
+    // recover in prod to reduce the impact on end-user
+    console.error(err)
+  }
+}
index 7f61656775c2a669dc17ded7788e784fc00bd6a3..d34c9f483f17c0882b0519ed251ae982b2664661 100644 (file)
@@ -41,6 +41,7 @@ export * from './on'
 export * from './render'
 export * from './template'
 export * from './scheduler'
+export * from './apiWatch'
 export * from './directive'
 export * from './dom'
 export * from './directives/vShow'
index a2f505ecdeb3c1a9192ea85d7a9b588992736a4a..1f53978e81d1b507a83d6723a22d6573f821e7fc 100644 (file)
@@ -17,6 +17,11 @@ export type ParentBlock = ParentNode | Node[]
 export type Fragment = { nodes: Block; anchor: Node }
 export type BlockFn = (props: any, ctx: any) => Block
 
+let isRenderingActivity = false
+export function getIsRendering() {
+  return isRenderingActivity
+}
+
 export function render(
   comp: Component,
   props: Data,
@@ -53,7 +58,13 @@ export function mountComponent(
     let block: Block | null = null
     if (state && '__isScriptSetup' in state) {
       instance.setupState = proxyRefs(state)
-      block = component.render(instance.proxy)
+      const currentlyRenderingActivity = isRenderingActivity
+      isRenderingActivity = true
+      try {
+        block = component.render(instance.proxy)
+      } finally {
+        isRenderingActivity = currentlyRenderingActivity
+      }
     } else {
       block = state as Block
     }
index 876e2c45e1b6ce28dc6082758f8b6b216e0f7696..703da98f6c5cf6f90eef69175dff604d4b03142a 100644 (file)
 import { ReactiveEffect } from '@vue/reactivity'
+import { ComponentInternalInstance } from './component'
+import { getIsRendering } from '.'
 
-const p = Promise.resolve()
+export interface SchedulerJob extends Function {
+  id?: number
+  pre?: boolean
+  active?: boolean
+  computed?: boolean
+  /**
+   * Indicates whether the effect is allowed to recursively trigger itself
+   * when managed by the scheduler.
+   *
+   * By default, a job cannot trigger itself because some built-in method calls,
+   * e.g. Array.prototype.push actually performs reads as well (#1740) which
+   * can lead to confusing infinite loops.
+   * The allowed cases are component update functions and watch callbacks.
+   * Component update functions may update child component props, which in turn
+   * trigger flush: "pre" watch callbacks that mutates state that the parent
+   * relies on (#1801). Watch callbacks doesn't track its dependencies so if it
+   * triggers itself again, it's likely intentional and it is the user's
+   * responsibility to perform recursive state mutation that eventually
+   * stabilizes (#1727).
+   */
+  allowRecurse?: boolean
+  /**
+   * Attached by renderer.ts when setting up a component's render effect
+   * Used to obtain component information when reporting max recursive updates.
+   * dev only.
+   */
+  ownerInstance?: ComponentInternalInstance
+}
+
+export type SchedulerJobs = SchedulerJob | SchedulerJob[]
+
+export type QueueEffect = (
+  cb: SchedulerJobs,
+  suspense: ComponentInternalInstance | null,
+) => void
+
+export type Scheduler = (context: {
+  effect: ReactiveEffect
+  job: SchedulerJob
+  instance: ComponentInternalInstance | null
+  isInit: boolean
+}) => void
+
+let isFlushing = false
+let isFlushPending = false
+
+// TODO: The queues in Vapor need to be merged with the queues in Core.
+//       this is a temporary solution, the ultimate goal is to support
+//       the mixed use of vapor components and default components.
+const queue: SchedulerJob[] = []
+let flushIndex = 0
+
+// TODO: The queues in Vapor need to be merged with the queues in Core.
+//       this is a temporary solution, the ultimate goal is to support
+//       the mixed use of vapor components and default components.
+const pendingPostFlushCbs: SchedulerJob[] = []
+let activePostFlushCbs: SchedulerJob[] | null = null
+let postFlushIndex = 0
+
+const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
+let currentFlushPromise: Promise<void> | null = null
+
+function queueJob(job: SchedulerJob) {
+  if (
+    !queue.length ||
+    !queue.includes(
+      job,
+      isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex,
+    )
+  ) {
+    if (job.id == null) {
+      queue.push(job)
+    } else {
+      queue.splice(findInsertionIndex(job.id), 0, job)
+    }
+    queueFlush()
+  }
+}
+
+export function queuePostRenderEffect(cb: SchedulerJob) {
+  if (
+    !activePostFlushCbs ||
+    !activePostFlushCbs.includes(
+      cb,
+      cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex,
+    )
+  ) {
+    pendingPostFlushCbs.push(cb)
+  }
+  queueFlush()
+}
+
+function queueFlush() {
+  if (!isFlushing && !isFlushPending) {
+    isFlushPending = true
+    currentFlushPromise = resolvedPromise.then(flushJobs)
+  }
+}
+
+function flushPostFlushCbs() {
+  if (!pendingPostFlushCbs.length) return
+
+  const deduped = [...new Set(pendingPostFlushCbs)]
+  pendingPostFlushCbs.length = 0
+
+  // #1947 already has active queue, nested flushPostFlushCbs call
+  if (activePostFlushCbs) {
+    activePostFlushCbs.push(...deduped)
+    return
+  }
+
+  activePostFlushCbs = deduped
+
+  activePostFlushCbs.sort((a, b) => getId(a) - getId(b))
+
+  for (
+    postFlushIndex = 0;
+    postFlushIndex < activePostFlushCbs.length;
+    postFlushIndex++
+  ) {
+    activePostFlushCbs[postFlushIndex]()
+  }
+  activePostFlushCbs = null
+  postFlushIndex = 0
+}
+
+// TODO: dev mode and checkRecursiveUpdates
+function flushJobs() {
+  isFlushPending = false
+  isFlushing = true
 
-let queued: any[] | undefined
+  // Sort queue before flush.
+  // This ensures that:
+  // 1. Components are updated from parent to child. (because parent is always
+  //    created before the child so its render effect will have smaller
+  //    priority number)
+  // 2. If a component is unmounted during a parent component's update,
+  //    its update can be skipped.
+  queue.sort(comparator)
 
-function queue(fn: any) {
-  if (!queued) {
-    queued = [fn]
-    p.then(flush)
+  try {
+    for (let i = 0; i < queue!.length; i++) {
+      queue![i]()
+    }
+  } finally {
+    flushIndex = 0
+    queue.length = 0
+
+    flushPostFlushCbs()
+
+    isFlushing = false
+    currentFlushPromise = null
+    // some postFlushCb queued jobs!
+    // keep flushing until it drains.
+    if (queue.length || pendingPostFlushCbs.length) {
+      flushJobs()
+    }
+  }
+}
+
+export function nextTick<T = void, R = void>(
+  this: T,
+  fn?: (this: T) => R,
+): Promise<Awaited<R>> {
+  const p = currentFlushPromise || resolvedPromise
+  return fn ? p.then(this ? fn.bind(this) : fn) : p
+}
+
+// #2768
+// Use binary-search to find a suitable position in the queue,
+// so that the queue maintains the increasing order of job's id,
+// which can prevent the job from being skipped and also can avoid repeated patching.
+function findInsertionIndex(id: number) {
+  // the start index should be `flushIndex + 1`
+  let start = flushIndex + 1
+  let end = queue.length
+
+  while (start < end) {
+    const middle = (start + end) >>> 1
+    const middleJob = queue[middle]
+    const middleJobId = getId(middleJob)
+    if (middleJobId < id || (middleJobId === id && middleJob.pre)) {
+      start = middle + 1
+    } else {
+      end = middle
+    }
+  }
+
+  return start
+}
+
+const getId = (job: SchedulerJob): number =>
+  job.id == null ? Infinity : job.id
+
+const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
+  const diff = getId(a) - getId(b)
+  if (diff === 0) {
+    if (a.pre && !b.pre) return -1
+    if (b.pre && !a.pre) return 1
+  }
+  return diff
+}
+
+export function getVaporSchedulerByFlushMode(
+  flush?: 'pre' | 'post' | 'sync',
+): Scheduler {
+  if (flush === 'post') {
+    return vaporPostScheduler
+  }
+  if (flush === 'sync') {
+    return vaporSyncScheduler
+  }
+  if (getIsRendering()) {
+    return vaporRenderingScheduler
+  }
+  // default: 'pre'
+  return vaporPreScheduler
+}
+
+export const vaporSyncScheduler: Scheduler = ({ isInit, effect, job }) => {
+  if (isInit) {
+    effect.run()
   } else {
-    queued.push(fn)
+    job()
   }
 }
 
-function flush() {
-  for (let i = 0; i < queued!.length; i++) {
-    queued![i]()
+export const vaporPreScheduler: Scheduler = ({
+  isInit,
+  effect,
+  instance,
+  job,
+}) => {
+  if (isInit) {
+    effect.run()
+  } else {
+    job.pre = true
+    if (instance) job.id = instance.uid
+    queueJob(job)
   }
-  queued = undefined
 }
 
-export const nextTick = (fn?: any) => (fn ? p.then(fn) : p)
+export const vaporRenderingScheduler: Scheduler = ({
+  isInit,
+  effect,
+  instance,
+  job,
+}) => {
+  if (isInit) {
+    effect.run()
+  } else {
+    job.pre = false
+    if (instance) job.id = instance.uid
+    queueJob(job)
+  }
+}
 
-export function effect(fn: any) {
-  let run: () => void
-  const e = new ReactiveEffect(fn, () => queue(run))
-  run = e.run.bind(e)
-  run()
+export const vaporPostScheduler: Scheduler = ({ isInit, effect, job }) => {
+  if (isInit) {
+    queuePostRenderEffect(effect.run.bind(effect))
+  } else {
+    queuePostRenderEffect(job)
+  }
 }
diff --git a/packages/runtime-vapor/src/warning.ts b/packages/runtime-vapor/src/warning.ts
new file mode 100644 (file)
index 0000000..c6cbdfe
--- /dev/null
@@ -0,0 +1,3 @@
+export function warn(msg: string, ...args: any[]) {
+  console.warn(`[Vue warn] ${msg}`, ...args)
+}
index b80768dcc49187a698610d0d5d62354584dc7599..695800ecd0923c6f00d2b9ebb24dde9b42a79d88 100644 (file)
@@ -4,7 +4,7 @@ import {
   on,
   ref,
   template,
-  effect,
+  watchEffect,
   setText,
   render as renderComponent // TODO:
 } from '@vue/vapor'
@@ -35,7 +35,7 @@ export default {
       0: [n1]
     } = children(n0)
     on(n1, 'click', _ctx.handleClick)
-    effect(() => {
+    watchEffect(() => {
       setText(n1, void 0, _ctx.count)
     })
 
@@ -96,7 +96,7 @@ const child = {
     const {
       0: [n1]
     } = children(n0)
-    effect(() => {
+    watchEffect(() => {
       setText(n1, void 0, _ctx.count + ' * 2 = ' + _ctx.inlineDouble)
     })
     return n0
diff --git a/playground/src/scheduler.vue b/playground/src/scheduler.vue
new file mode 100644 (file)
index 0000000..a301934
--- /dev/null
@@ -0,0 +1,61 @@
+<script setup lang="ts">
+import { onEffectCleanup, ref, watch, watchEffect, watchPostEffect, watchSyncEffect } from 'vue/vapor'
+
+const source = ref(0)
+const add = () => source.value++
+
+watchPostEffect(() => {
+  const current = source.value
+  console.log('post', current)
+  onEffectCleanup(() => console.log('cleanup post', current))
+})
+
+watchEffect(() => {
+  const current = source.value
+  console.log('pre', current)
+  onEffectCleanup(() => console.log('cleanup pre', current))
+})
+
+watchSyncEffect(() => {
+  const current = source.value
+  console.log('sync', current)
+  onEffectCleanup(() => console.log('cleanup sync', current))
+})
+
+watch(source, (value, oldValue) => {
+  console.log('sync watch', value, 'oldValue:', oldValue)
+  onEffectCleanup(() => console.log('cleanup sync watch', value))
+})
+
+const onUpdate = (arg: any) => {
+  const current = source.value
+  console.log('render', current)
+  onEffectCleanup(() => console.log('cleanup render', current))
+  return arg
+}
+</script>
+
+<template>
+  <div>
+    <p>Please check the console</p>
+    <div>
+      <button @click="add">
+        Add
+      </button>
+      |
+      <span>{{ onUpdate(source) }}</span>
+    </div>
+  </div>
+</template>
+
+<style>
+.red {
+  color: red;
+}
+
+html {
+  color-scheme: dark;
+  background-color: #000;
+  padding: 10px;
+}
+</style>