From 74fc0e46a1949313546fe54b665251d671effb88 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 25 Dec 2024 00:11:01 +0800 Subject: [PATCH 1/8] feat: asyncComputed, asyncEffect --- src/asyncComputed.ts | 61 +++++++++++++++++++++++++++++++ src/asyncEffect.ts | 58 ++++++++++++++++++++++++++++++ src/index.ts | 2 ++ src/system.ts | 71 +++++++++++++++++++++++++++++++++++++ tests/asyncComputed.spec.ts | 35 ++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 src/asyncComputed.ts create mode 100644 src/asyncEffect.ts create mode 100644 tests/asyncComputed.spec.ts diff --git a/src/asyncComputed.ts b/src/asyncComputed.ts new file mode 100644 index 0000000..a302015 --- /dev/null +++ b/src/asyncComputed.ts @@ -0,0 +1,61 @@ +import { Computed } from './computed.js'; +import { nextTrackId } from './effect.js'; +import { checkDirty, Dependency, endTrack, link, shallowPropagate, startTrack, SubscriberFlags } from './system.js'; + +export function asyncComputed(getter: (cachedValue?: T) => AsyncGenerator): AsyncComputed { + return new AsyncComputed(getter); +} + +export class AsyncComputed extends Computed { + + async get(): Promise { + const flags = this.flags; + if (flags & SubscriberFlags.Dirty) { + if (await this.update()) { + const subs = this.subs; + if (subs !== undefined) { + shallowPropagate(subs); + } + } + } else if (flags & SubscriberFlags.ToCheckDirty) { + if (checkDirty(this.deps!)) { + if (await this.update()) { + const subs = this.subs; + if (subs !== undefined) { + shallowPropagate(subs); + } + } + } else { + this.flags = flags & ~SubscriberFlags.ToCheckDirty; + } + } + return this.currentValue!; + } + + // @ts-expect-error + async update(): Promise { + try { + startTrack(this); + const trackId = nextTrackId(); + const oldValue = this.currentValue; + const generator = this.getter(oldValue); + let current = await generator.next(); + while (!current.done) { + const dep = current.value; + if (dep.lastTrackedId !== trackId) { + dep.lastTrackedId = trackId; + link(dep, this); + } + current = await generator.next(); + } + const newValue = await current.value; + if (oldValue !== newValue) { + this.currentValue = newValue; + return true; + } + return false; + } finally { + endTrack(this); + } + } +} diff --git a/src/asyncEffect.ts b/src/asyncEffect.ts new file mode 100644 index 0000000..b61834e --- /dev/null +++ b/src/asyncEffect.ts @@ -0,0 +1,58 @@ +import { Effect, nextTrackId } from './effect.js'; +import { asyncCheckDirty, Dependency, endTrack, link, startTrack, SubscriberFlags } from './system.js'; + +export function asyncEffect(fn: () => AsyncGenerator): AsyncEffect { + const e = new AsyncEffect(fn); + e.run(); + return e; +} + +export class AsyncEffect extends Effect { + + async notify(): Promise { + let flags = this.flags; + if (flags & SubscriberFlags.Dirty) { + this.run(); + return; + } + if (flags & SubscriberFlags.ToCheckDirty) { + if (await asyncCheckDirty(this.deps!)) { + this.run(); + return; + } else { + this.flags = flags &= ~SubscriberFlags.ToCheckDirty; + } + } + if (flags & SubscriberFlags.InnerEffectsPending) { + this.flags = flags & ~SubscriberFlags.InnerEffectsPending; + let link = this.deps!; + do { + const dep = link.dep; + if ('notify' in dep) { + dep.notify(); + } + link = link.nextDep!; + } while (link !== undefined); + } + } + + async run(): Promise { + try { + startTrack(this); + const trackId = nextTrackId(); + const generator = this.fn(); + let current = await generator.next(); + while (!current.done) { + const dep = current.value; + if (dep.lastTrackedId !== trackId) { + dep.lastTrackedId = trackId; + link(dep, this); + } + current = await generator.next(); + } + return await current.value; + } finally { + endTrack(this); + } + } +} diff --git a/src/index.ts b/src/index.ts index 231f42e..1bdfb26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +export * from './asyncComputed.js'; +export * from './asyncEffect.js'; export * from './computed.js'; export * from './effect.js'; export * from './effectScope.js'; diff --git a/src/system.ts b/src/system.ts index 6a11c44..9b952cf 100644 --- a/src/system.ts +++ b/src/system.ts @@ -324,6 +324,77 @@ export function checkDirty(link: Link): boolean { } while (true); } +export async function asyncCheckDirty(link: Link): Promise { + let stack = 0; + let dirty: boolean; + let nextDep: Link | undefined; + + top: do { + dirty = false; + const dep = link.dep; + if ('update' in dep) { + const depFlags = dep.flags; + if (depFlags & SubscriberFlags.Dirty) { + if (await dep.update()) { + const subs = dep.subs!; + if (subs.nextSub !== undefined) { + shallowPropagate(subs); + } + dirty = true; + } + } else if (depFlags & SubscriberFlags.ToCheckDirty) { + const depSubs = dep.subs!; + if (depSubs.nextSub !== undefined) { + depSubs.prevSub = link; + } + link = dep.deps!; + ++stack; + continue; + } + } + if (dirty || (nextDep = link.nextDep) === undefined) { + if (stack) { + let sub = link.sub as IComputed; + do { + --stack; + const subSubs = sub.subs!; + let prevLink = subSubs.prevSub!; + if (prevLink !== undefined) { + subSubs.prevSub = undefined; + if (dirty) { + if (await sub.update()) { + shallowPropagate(sub.subs!); + sub = prevLink.sub as IComputed; + continue; + } + } else { + sub.flags &= ~SubscriberFlags.ToCheckDirty; + } + } else { + if (dirty) { + if (await sub.update()) { + sub = subSubs.sub as IComputed; + continue; + } + } else { + sub.flags &= ~SubscriberFlags.ToCheckDirty; + } + prevLink = subSubs; + } + link = prevLink.nextDep!; + if (link !== undefined) { + continue top; + } + sub = prevLink.sub as IComputed; + dirty = false; + } while (stack); + } + return dirty; + } + link = nextDep; + } while (true); +} + export function startTrack(sub: Subscriber): void { sub.depsTail = undefined; sub.flags = SubscriberFlags.Tracking; diff --git a/tests/asyncComputed.spec.ts b/tests/asyncComputed.spec.ts new file mode 100644 index 0000000..7ccba89 --- /dev/null +++ b/tests/asyncComputed.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'vitest'; +import { asyncComputed, asyncEffect } from '../src'; +import { signal } from './api'; + +test('should track dep after await', async () => { + const src = signal(0); + const c = asyncComputed(async function* () { + await new Promise(r => setTimeout(r, 1)); + return (yield src, src).get(); + }); + expect(await c.get()).toBe(0); + + src.set(1); + expect(await c.get()).toBe(1); +}); + +test('should trigger effect', async () => { + let triggerTimes = 0; + + const src = signal(0); + const c = asyncComputed(async function* () { + await new Promise(r => setTimeout(r, 1)); + return (yield src, src).get(); + }); + asyncEffect(async function* () { + triggerTimes++; + (yield c, c).get(); + }); + expect(triggerTimes).toBe(1); + + await new Promise(r => setTimeout(r, 2)); + src.set(1); + await new Promise(r => setTimeout(r, 2)); + expect(triggerTimes).toBe(2); +}); From 3da58834aaff7e65713b282ab83c91fc1432ad46 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 25 Dec 2024 00:39:39 +0800 Subject: [PATCH 2/8] Update asyncComputed.spec.ts --- tests/asyncComputed.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/asyncComputed.spec.ts b/tests/asyncComputed.spec.ts index 7ccba89..b1dae7f 100644 --- a/tests/asyncComputed.spec.ts +++ b/tests/asyncComputed.spec.ts @@ -14,7 +14,7 @@ test('should track dep after await', async () => { expect(await c.get()).toBe(1); }); -test('should trigger effect', async () => { +test('should trigger asyncEffect', async () => { let triggerTimes = 0; const src = signal(0); From 9f70704e3a618f03eb43b7b594bff9d48a70c97c Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 25 Dec 2024 01:47:21 +0800 Subject: [PATCH 3/8] Update asyncComputed.ts --- src/asyncComputed.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/asyncComputed.ts b/src/asyncComputed.ts index a302015..0e6510f 100644 --- a/src/asyncComputed.ts +++ b/src/asyncComputed.ts @@ -1,6 +1,6 @@ import { Computed } from './computed.js'; import { nextTrackId } from './effect.js'; -import { checkDirty, Dependency, endTrack, link, shallowPropagate, startTrack, SubscriberFlags } from './system.js'; +import { asyncCheckDirty, Dependency, endTrack, link, shallowPropagate, startTrack, SubscriberFlags } from './system.js'; export function asyncComputed(getter: (cachedValue?: T) => AsyncGenerator): AsyncComputed { return new AsyncComputed(getter); @@ -18,7 +18,7 @@ export class AsyncComputed extends Computed { } } } else if (flags & SubscriberFlags.ToCheckDirty) { - if (checkDirty(this.deps!)) { + if (await asyncCheckDirty(this.deps!)) { if (await this.update()) { const subs = this.subs; if (subs !== undefined) { From 7fa8b88da427d8dfe2af6f5f4b61fdf6d47e83ea Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 25 Dec 2024 04:10:00 +0800 Subject: [PATCH 4/8] Update asyncComputed.spec.ts --- tests/asyncComputed.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/asyncComputed.spec.ts b/tests/asyncComputed.spec.ts index b1dae7f..ea10d2a 100644 --- a/tests/asyncComputed.spec.ts +++ b/tests/asyncComputed.spec.ts @@ -5,7 +5,7 @@ import { signal } from './api'; test('should track dep after await', async () => { const src = signal(0); const c = asyncComputed(async function* () { - await new Promise(r => setTimeout(r, 1)); + await new Promise(r => setTimeout(r, 100)); return (yield src, src).get(); }); expect(await c.get()).toBe(0); @@ -19,7 +19,7 @@ test('should trigger asyncEffect', async () => { const src = signal(0); const c = asyncComputed(async function* () { - await new Promise(r => setTimeout(r, 1)); + await new Promise(r => setTimeout(r, 100)); return (yield src, src).get(); }); asyncEffect(async function* () { @@ -28,8 +28,8 @@ test('should trigger asyncEffect', async () => { }); expect(triggerTimes).toBe(1); - await new Promise(r => setTimeout(r, 2)); + await new Promise(r => setTimeout(r, 200)); src.set(1); - await new Promise(r => setTimeout(r, 2)); + await new Promise(r => setTimeout(r, 200)); expect(triggerTimes).toBe(2); }); From fa0a36399026733840e44146cac3258e5079b92f Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 25 Dec 2024 05:09:43 +0800 Subject: [PATCH 5/8] Update asyncComputed.spec.ts --- tests/asyncComputed.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/asyncComputed.spec.ts b/tests/asyncComputed.spec.ts index ea10d2a..b27a5e4 100644 --- a/tests/asyncComputed.spec.ts +++ b/tests/asyncComputed.spec.ts @@ -5,7 +5,7 @@ import { signal } from './api'; test('should track dep after await', async () => { const src = signal(0); const c = asyncComputed(async function* () { - await new Promise(r => setTimeout(r, 100)); + await sleep(100); return (yield src, src).get(); }); expect(await c.get()).toBe(0); @@ -19,7 +19,7 @@ test('should trigger asyncEffect', async () => { const src = signal(0); const c = asyncComputed(async function* () { - await new Promise(r => setTimeout(r, 100)); + await sleep(100); return (yield src, src).get(); }); asyncEffect(async function* () { @@ -28,8 +28,12 @@ test('should trigger asyncEffect', async () => { }); expect(triggerTimes).toBe(1); - await new Promise(r => setTimeout(r, 200)); + await sleep(200); src.set(1); - await new Promise(r => setTimeout(r, 200)); + await sleep(200); expect(triggerTimes).toBe(2); }); + +function sleep(ms: number) { + return new Promise(r => setTimeout(r, ms)); +} From 22b19862180b354e757fb899c3c27a077b8e6935 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 25 Dec 2024 05:14:02 +0800 Subject: [PATCH 6/8] Move to unstable --- src/index.ts | 2 - src/system.ts | 71 ---------------------------- src/{ => unstable}/asyncComputed.ts | 11 +++-- src/{ => unstable}/asyncEffect.ts | 5 +- src/unstable/asyncSystem.ts | 72 +++++++++++++++++++++++++++++ src/unstable/index.ts | 3 ++ 6 files changed, 86 insertions(+), 78 deletions(-) rename src/{ => unstable}/asyncComputed.ts (80%) rename src/{ => unstable}/asyncEffect.ts (86%) create mode 100644 src/unstable/asyncSystem.ts diff --git a/src/index.ts b/src/index.ts index 1bdfb26..231f42e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,3 @@ -export * from './asyncComputed.js'; -export * from './asyncEffect.js'; export * from './computed.js'; export * from './effect.js'; export * from './effectScope.js'; diff --git a/src/system.ts b/src/system.ts index 9b952cf..6a11c44 100644 --- a/src/system.ts +++ b/src/system.ts @@ -324,77 +324,6 @@ export function checkDirty(link: Link): boolean { } while (true); } -export async function asyncCheckDirty(link: Link): Promise { - let stack = 0; - let dirty: boolean; - let nextDep: Link | undefined; - - top: do { - dirty = false; - const dep = link.dep; - if ('update' in dep) { - const depFlags = dep.flags; - if (depFlags & SubscriberFlags.Dirty) { - if (await dep.update()) { - const subs = dep.subs!; - if (subs.nextSub !== undefined) { - shallowPropagate(subs); - } - dirty = true; - } - } else if (depFlags & SubscriberFlags.ToCheckDirty) { - const depSubs = dep.subs!; - if (depSubs.nextSub !== undefined) { - depSubs.prevSub = link; - } - link = dep.deps!; - ++stack; - continue; - } - } - if (dirty || (nextDep = link.nextDep) === undefined) { - if (stack) { - let sub = link.sub as IComputed; - do { - --stack; - const subSubs = sub.subs!; - let prevLink = subSubs.prevSub!; - if (prevLink !== undefined) { - subSubs.prevSub = undefined; - if (dirty) { - if (await sub.update()) { - shallowPropagate(sub.subs!); - sub = prevLink.sub as IComputed; - continue; - } - } else { - sub.flags &= ~SubscriberFlags.ToCheckDirty; - } - } else { - if (dirty) { - if (await sub.update()) { - sub = subSubs.sub as IComputed; - continue; - } - } else { - sub.flags &= ~SubscriberFlags.ToCheckDirty; - } - prevLink = subSubs; - } - link = prevLink.nextDep!; - if (link !== undefined) { - continue top; - } - sub = prevLink.sub as IComputed; - dirty = false; - } while (stack); - } - return dirty; - } - link = nextDep; - } while (true); -} - export function startTrack(sub: Subscriber): void { sub.depsTail = undefined; sub.flags = SubscriberFlags.Tracking; diff --git a/src/asyncComputed.ts b/src/unstable/asyncComputed.ts similarity index 80% rename from src/asyncComputed.ts rename to src/unstable/asyncComputed.ts index 0e6510f..91fcd39 100644 --- a/src/asyncComputed.ts +++ b/src/unstable/asyncComputed.ts @@ -1,6 +1,7 @@ -import { Computed } from './computed.js'; -import { nextTrackId } from './effect.js'; -import { asyncCheckDirty, Dependency, endTrack, link, shallowPropagate, startTrack, SubscriberFlags } from './system.js'; +import { Computed } from '../computed.js'; +import { nextTrackId } from '../effect.js'; +import { Dependency, endTrack, link, shallowPropagate, startTrack, SubscriberFlags } from '../system.js'; +import { asyncCheckDirty } from './asyncSystem.js'; export function asyncComputed(getter: (cachedValue?: T) => AsyncGenerator): AsyncComputed { return new AsyncComputed(getter); @@ -47,6 +48,10 @@ export class AsyncComputed extends Computed { link(dep, this); } current = await generator.next(); + + if (this.flags & SubscriberFlags.Recursed) { + return await this.get() !== oldValue; + } } const newValue = await current.value; if (oldValue !== newValue) { diff --git a/src/asyncEffect.ts b/src/unstable/asyncEffect.ts similarity index 86% rename from src/asyncEffect.ts rename to src/unstable/asyncEffect.ts index b61834e..1536267 100644 --- a/src/asyncEffect.ts +++ b/src/unstable/asyncEffect.ts @@ -1,5 +1,6 @@ -import { Effect, nextTrackId } from './effect.js'; -import { asyncCheckDirty, Dependency, endTrack, link, startTrack, SubscriberFlags } from './system.js'; +import { Effect, nextTrackId } from '../effect.js'; +import { Dependency, endTrack, link, startTrack, SubscriberFlags } from '../system.js'; +import { asyncCheckDirty } from './asyncSystem.js'; export function asyncEffect(fn: () => AsyncGenerator): AsyncEffect { const e = new AsyncEffect(fn); diff --git a/src/unstable/asyncSystem.ts b/src/unstable/asyncSystem.ts new file mode 100644 index 0000000..7a9771d --- /dev/null +++ b/src/unstable/asyncSystem.ts @@ -0,0 +1,72 @@ +import { IComputed, Link, shallowPropagate, SubscriberFlags } from "../system"; + +export async function asyncCheckDirty(link: Link): Promise { + let stack = 0; + let dirty: boolean; + let nextDep: Link | undefined; + + top: do { + dirty = false; + const dep = link.dep; + if ('update' in dep) { + const depFlags = dep.flags; + if (depFlags & SubscriberFlags.Dirty) { + if (await dep.update()) { + const subs = dep.subs!; + if (subs.nextSub !== undefined) { + shallowPropagate(subs); + } + dirty = true; + } + } else if (depFlags & SubscriberFlags.ToCheckDirty) { + const depSubs = dep.subs!; + if (depSubs.nextSub !== undefined) { + depSubs.prevSub = link; + } + link = dep.deps!; + ++stack; + continue; + } + } + if (dirty || (nextDep = link.nextDep) === undefined) { + if (stack) { + let sub = link.sub as IComputed; + do { + --stack; + const subSubs = sub.subs!; + let prevLink = subSubs.prevSub!; + if (prevLink !== undefined) { + subSubs.prevSub = undefined; + if (dirty) { + if (await sub.update()) { + shallowPropagate(sub.subs!); + sub = prevLink.sub as IComputed; + continue; + } + } else { + sub.flags &= ~SubscriberFlags.ToCheckDirty; + } + } else { + if (dirty) { + if (await sub.update()) { + sub = subSubs.sub as IComputed; + continue; + } + } else { + sub.flags &= ~SubscriberFlags.ToCheckDirty; + } + prevLink = subSubs; + } + link = prevLink.nextDep!; + if (link !== undefined) { + continue top; + } + sub = prevLink.sub as IComputed; + dirty = false; + } while (stack); + } + return dirty; + } + link = nextDep; + } while (true); +} diff --git a/src/unstable/index.ts b/src/unstable/index.ts index 75862d3..3ac093d 100644 --- a/src/unstable/index.ts +++ b/src/unstable/index.ts @@ -1,3 +1,6 @@ +export * from './asyncComputed.js'; +export * from './asyncEffect.js'; +export * from './asyncSystem.js'; export * from './computedArray.js'; export * from './computedSet.js'; export * from './equalityComputed.js'; From d1a14c00684de3373362dfd6e41a173f8acc5929 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 25 Dec 2024 05:14:13 +0800 Subject: [PATCH 7/8] Update asyncComputed.spec.ts --- tests/asyncComputed.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/asyncComputed.spec.ts b/tests/asyncComputed.spec.ts index b27a5e4..a81b82a 100644 --- a/tests/asyncComputed.spec.ts +++ b/tests/asyncComputed.spec.ts @@ -1,7 +1,9 @@ import { expect, test } from 'vitest'; -import { asyncComputed, asyncEffect } from '../src'; +import { unstable } from '../src'; import { signal } from './api'; +const { asyncComputed, asyncEffect } = unstable; + test('should track dep after await', async () => { const src = signal(0); const c = asyncComputed(async function* () { From faac63a6401804da63a2c600a67daebea1b366ec Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Wed, 25 Dec 2024 05:15:00 +0800 Subject: [PATCH 8/8] Skip recursed handle --- src/unstable/asyncComputed.ts | 6 +++--- tests/asyncComputed.spec.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/unstable/asyncComputed.ts b/src/unstable/asyncComputed.ts index 91fcd39..3cd961c 100644 --- a/src/unstable/asyncComputed.ts +++ b/src/unstable/asyncComputed.ts @@ -49,9 +49,9 @@ export class AsyncComputed extends Computed { } current = await generator.next(); - if (this.flags & SubscriberFlags.Recursed) { - return await this.get() !== oldValue; - } + // if (this.flags & SubscriberFlags.Recursed) { + // return await this.get() !== oldValue; + // } } const newValue = await current.value; if (oldValue !== newValue) { diff --git a/tests/asyncComputed.spec.ts b/tests/asyncComputed.spec.ts index a81b82a..11f1c75 100644 --- a/tests/asyncComputed.spec.ts +++ b/tests/asyncComputed.spec.ts @@ -36,6 +36,32 @@ test('should trigger asyncEffect', async () => { expect(triggerTimes).toBe(2); }); +test.skip('should stop calculating when dep updated', async () => { + let calcTimes = 0; + + const a = signal('a0'); + const b = signal('b0'); + const c = asyncComputed(async function* () { + calcTimes++; + const v1 = (yield a, a).get(); + await sleep(200); + const v2 = (yield b, b).get(); + return v1 + '-' + v2; + }); + + expect(await c.get()).toBe('a0-b0'); + expect(calcTimes).toBe(1); + + a.set('a1'); + const promise = c.get(); + await sleep(100); + expect(calcTimes).toBe(2); + a.set('a2'); + + expect(await promise).toBe('a2-b0'); + expect(calcTimes).toBe(3); +}); + function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)); }