From f6b344bc13e5d094698ffeb4e971563db7a1cc95 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Thu, 7 Nov 2024 09:27:59 +0100 Subject: [PATCH 01/18] Rewriting tansu with a signal-first approach --- eslint.config.mjs | 3 +- src/index.spec.ts | 362 ++++++-- src/index.ts | 1100 +++--------------------- src/internal/batch.ts | 76 ++ src/internal/equal.ts | 12 + src/internal/exposeRawStores.ts | 48 ++ src/internal/store.ts | 259 ++++++ src/internal/storeComputed.ts | 142 +++ src/internal/storeComputedOrDerived.ts | 80 ++ src/internal/storeConst.ts | 32 + src/internal/storeDerived.ts | 143 +++ src/internal/storeSubscribable.ts | 40 + src/internal/storeTrackingUsage.ts | 83 ++ src/internal/storeWithOnUse.ts | 29 + src/internal/unsubscribe.ts | 19 + src/internal/untrack.ts | 31 + src/types.ts | 259 ++++++ 17 files changed, 1638 insertions(+), 1080 deletions(-) create mode 100644 src/internal/batch.ts create mode 100644 src/internal/equal.ts create mode 100644 src/internal/exposeRawStores.ts create mode 100644 src/internal/store.ts create mode 100644 src/internal/storeComputed.ts create mode 100644 src/internal/storeComputedOrDerived.ts create mode 100644 src/internal/storeConst.ts create mode 100644 src/internal/storeDerived.ts create mode 100644 src/internal/storeSubscribable.ts create mode 100644 src/internal/storeTrackingUsage.ts create mode 100644 src/internal/storeWithOnUse.ts create mode 100644 src/internal/unsubscribe.ts create mode 100644 src/internal/untrack.ts create mode 100644 src/types.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 069dcad..8ba8055 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,7 +29,8 @@ export default [ '@typescript-eslint/no-empty-function': 0, '@typescript-eslint/no-non-null-assertion': 0, '@typescript-eslint/explicit-module-boundary-types': 2, - '@typescript-eslint/no-unused-vars': 2, + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/consistent-type-imports': 2, }, }, ]; diff --git a/src/index.spec.ts b/src/index.spec.ts index 833ec75..da7a545 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -3,30 +3,43 @@ import { Component, Injectable, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { BehaviorSubject, from } from 'rxjs'; import { writable as svelteWritable } from 'svelte/store'; -import { describe, expect, it, vi } from 'vitest'; -import { - DerivedStore, +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Readable, ReadableSignal, - Store, StoreInput, StoreOptions, StoresInput, StoresInputValues, SubscribableStore, SubscriberObject, - asWritable, +} from './index'; +import { + DerivedStore, + Store, asReadable, - equal, + asWritable, batch, computed, derived, + equal, get, readable, symbolObservable, untrack, writable, } from './index'; +import { rawStoreSymbol } from './internal/exposeRawStores'; +import { RawStoreFlags, type RawStore } from './internal/store'; +import { flushUnused } from './internal/storeTrackingUsage'; + +const expectCorrectlyCleanedUp = (store: StoreInput) => { + const rawStore = (store as any)[rawStoreSymbol] as RawStore; + expect(rawStore.consumerLinks?.length ?? 0).toBe(0); + expect(rawStore.flags & RawStoreFlags.START_USE_CALLED).toBeFalsy(); +}; + +afterEach(flushUnused); const customSimpleWritable = ( value: T @@ -152,6 +165,93 @@ describe('stores', () => { expect(store2()).toBe(1); }); + it('should throw when trying to read a signal during the notification phase', () => { + const store = writable(0); + let success = 0; + const errors: any[] = []; + const unsubscribe = store.subscribe({ + pause() { + try { + store.get(); + success++; + } catch (error) { + errors.push(error); + } + }, + }); + store.set(1); + expect(success).toBe(0); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain('during the notification phase'); + unsubscribe(); + }); + + it('should throw when trying to read an up-to-date computed signal during the notification phase', () => { + const w1 = writable(0); + const s1 = computed(() => w1()); + s1(); + const store = writable(0); + let success = 0; + const errors: any[] = []; + const unsubscribe = store.subscribe({ + pause() { + try { + s1.get(); + success++; + } catch (error) { + errors.push(error); + } + }, + }); + store.set(1); + expect(success).toBe(0); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain('during the notification phase'); + unsubscribe(); + }); + + it('should throw when trying to subscribe to a signal during the notification phase', () => { + const store = writable(0); + let success = 0; + const errors: any[] = []; + const unsubscribe = store.subscribe({ + pause() { + try { + store.subscribe(() => {}); + success++; + } catch (error) { + errors.push(error); + } + }, + }); + store.set(1); + expect(success).toBe(0); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain('during the notification phase'); + unsubscribe(); + }); + + it('should throw when trying to write a signal during notification phase', () => { + const store = writable(0); + let success = 0; + const errors: any[] = []; + const unsubscribe = store.subscribe({ + pause() { + try { + store.set(2); + success++; + } catch (error) { + errors.push(error); + } + }, + }); + store.set(1); + expect(success).toBe(0); + expect(errors.length).toBe(1); + expect(errors[0].message).toContain('during the notification phase'); + unsubscribe(); + }); + it('should work to use subscribe only and use it in a computed', () => { const store1 = writable(0); const store2 = asReadable({ subscribe: store1.subscribe }); @@ -444,34 +544,6 @@ describe('stores', () => { unsubscribe(); }); - it('should not call again listeners when only resuming subscribers', () => { - class BasicStore extends Store { - public override pauseSubscribers(): void { - super.pauseSubscribers(); - } - public override resumeSubscribers(): void { - super.resumeSubscribers(); - } - public override set(value: object): void { - super.set(value); - } - } - const initialValue = {}; - const newValue = {}; - const store = new BasicStore(initialValue); - const calls: object[] = []; - const unsubscribe = store.subscribe((v) => calls.push(v)); - expect(calls.length).toBe(1); - expect(calls[0]).toBe(initialValue); - store.pauseSubscribers(); - store.resumeSubscribers(); - expect(calls.length).toBe(1); - store.set(newValue); - expect(calls.length).toBe(2); - expect(calls[1]).toBe(newValue); - unsubscribe(); - }); - it('asReadable should be compatible with rxjs (BehaviorSubject)', () => { const behaviorSubject = new BehaviorSubject(0); const store = asReadable(behaviorSubject); @@ -512,39 +584,6 @@ describe('stores', () => { expect(values).toEqual([0, 1]); }); - it('asReadable should not wrap its output subscribe function into a new wrapper when called again (BehaviorSubject)', () => { - const input = new BehaviorSubject(0); - const readable1 = asReadable(input); - const readable2 = asReadable(readable1); - expect(readable1.subscribe).toBe(readable2.subscribe); - }); - - it('asReadable should not wrap its output subscribe function into a new wrapper when called again (InteropObservable)', () => { - const b = new BehaviorSubject(1); - const input = { [symbolObservable]: () => b }; - const readable1 = asReadable(input); - const readable2 = asReadable(readable1); - expect(readable1.subscribe).toBe(readable2.subscribe); - }); - - it('asReadable should not wrap the readable (const store) subscribe function into a new wrapper', () => { - const readable1 = readable(5); - const readable2 = asReadable(readable1); - expect(readable1.subscribe).toBe(readable2.subscribe); - }); - - it('asReadable should not wrap the readable (with onUse) subscribe function into a new wrapper', () => { - const readable1 = readable(5, { onUse() {} }); - const readable2 = asReadable(readable1); - expect(readable1.subscribe).toBe(readable2.subscribe); - }); - - it('asReadable should not wrap the writable subscribe function into a new wrapper', () => { - const readable1 = writable(5); - const readable2 = asReadable(readable1); - expect(readable1.subscribe).toBe(readable2.subscribe); - }); - it('asReadable should work nicely as a return value of a function whose type is explicitly defined', () => { interface Counter extends ReadableSignal { increment(): void; @@ -723,6 +762,26 @@ describe('stores', () => { expect(one()).toBe(1); }); + it('should work to call a constant store in a derived', () => { + const a = readable(0); + const b = derived(a, (a) => a + 1); + expect(b()).toEqual(1); + const values: number[] = []; + const unsubscribe = b.subscribe((v) => values.push(v)); + expect(values).toEqual([1]); + unsubscribe(); + }); + + it('should work to call a constant store in a computed', () => { + const a = readable(0); + const b = computed(() => a() + 1); + expect(b()).toEqual(1); + const values: number[] = []; + const unsubscribe = b.subscribe((v) => values.push(v)); + expect(values).toEqual([1]); + unsubscribe(); + }); + it('should work to subscribe without a listener', () => { let used = 0; const a = readable(0, () => { @@ -874,7 +933,7 @@ describe('stores', () => { ]); }); - it('should be able to use destructuring', () => { + it('should be able to use destructuring (constant store)', () => { const store = readable(0); const { subscribe } = store; @@ -884,6 +943,19 @@ describe('stores', () => { unsubscribe(); }); + + it('should be able to use destructuring (non-constant store)', () => { + const store = readable(0, (set) => { + set(1); + }); + const { subscribe } = store; + + const values: Array = []; + const unsubscribe = subscribe((v) => values.push(v)); + expect(values).toEqual([1]); + + unsubscribe(); + }); }); describe('writable', () => { @@ -2089,26 +2161,22 @@ describe('stores', () => { unsubscribe(); }); - it('should work with a derived function that subscribes to itself', () => { + it('should throw if a derived function subscribes to itself', () => { const store = writable(0); - let derivedCalls = 0; - let innerUnsubscribe: undefined | (() => void); - const innerSubscriptionCalls: any[] = []; const derivedStore = derived(store, (value) => { - derivedCalls++; - if (!innerUnsubscribe) { - // the first call of the listener should contain undefined as the value is not yet computed - innerUnsubscribe = derivedStore.subscribe((value) => innerSubscriptionCalls.push(value)); - } + // calling subscribe here should throw a "recursive computed" error + derivedStore.subscribe(() => {}); return value; }); - const calls: number[] = []; - const unsubscribe = derivedStore.subscribe((n: number) => calls.push(n)); - expect(derivedCalls).toBe(1); - expect(innerSubscriptionCalls).toEqual([undefined, 0]); - expect(calls).toEqual([0]); - unsubscribe(); - innerUnsubscribe!(); + expect(() => { + derivedStore.subscribe(() => {}); + }).toThrow('recursive computed'); + }); + + it('should throw when reading a derived that calls itself', () => { + const store = writable(0); + const c = derived(store, (): number => c()); + expect(c).toThrowError('recursive computed'); }); it('should work with a basic switchMap', () => { @@ -2137,7 +2205,8 @@ describe('stores', () => { const b = writable(2); const c = writable(0); const spy = vi.spyOn(a, 'subscribe'); - const d = switchMap(c, (c) => (c % 2 === 0 ? a : b)); + const aWithSpy = { subscribe: a.subscribe }; + const d = switchMap(c, (c) => (c % 2 === 0 ? aWithSpy : b)); const values: number[] = []; const unsubscribe = d.subscribe((value) => values.push(value)); expect(spy).toHaveBeenCalledTimes(1); @@ -2829,6 +2898,19 @@ describe('stores', () => { }); describe('computed', () => { + it('should work with a basic store class', () => { + class CounterStore extends Store { + increase() { + this.update((value) => value + 1); + } + } + const store = new CounterStore(0); + const doubleStore = computed(() => store.get() * 2); + expect(doubleStore()).toBe(0); + store.increase(); + expect(doubleStore()).toBe(2); + }); + it('should not call equal with undefined during the first computation', () => { const a = writable(1); const equal = vi.fn(Object.is); @@ -2990,6 +3072,11 @@ describe('stores', () => { expect(values).toEqual([]); }); + it('should throw when reading a computed that calls itself in untracked', () => { + const c = computed((): number => untrack(c)); + expect(c).toThrowError('recursive computed'); + }); + it('should throw when setting a value that triggers a recursive computed', () => { const recursive = writable(false); const myValue = computed((): number => (recursive() ? myValue() : 0)); @@ -3201,5 +3288,110 @@ describe('stores', () => { bUnsubscribe(); cUnsubscribe(); }); + + it('should prevent the diamond dependency problem', () => { + const a = writable(0); + const b = computed(() => `b${a()}`); + const c = computed(() => `c${a()}`); + const dFn = vi.fn(() => `${b()}${c()}`); + const d = computed(dFn); + + const values: string[] = []; + + const unsubscribe = d.subscribe((value) => { + values.push(value); + }); + expect(dFn).toHaveBeenCalledTimes(1); + expect(values).toEqual(['b0c0']); + a.set(1); + expect(dFn).toHaveBeenCalledTimes(2); + expect(values).toEqual(['b0c0', 'b1c1']); + unsubscribe(); + }); + + it('should prevent the asymmetric diamond dependency problem', () => { + const a = writable(0); + const b = computed(() => `b${a()}`); + const cFn = vi.fn(() => `${a()}${b()}`); + const c = computed(cFn); + + const values: string[] = []; + + const unsubscribe = c.subscribe((value) => { + values.push(value); + }); + expect(cFn).toHaveBeenCalledTimes(1); + expect(values).toEqual(['0b0']); + a.set(1); + expect(cFn).toHaveBeenCalledTimes(2); + expect(values).toEqual(['0b0', '1b1']); + unsubscribe(); + }); + + it('should call the function with no scope', () => { + let calls = 0; + let scope: any; + const c = computed(function (this: any) { + calls++; + // eslint-disable-next-line @typescript-eslint/no-this-alias + scope = this; + }); + c(); + expect(calls).toBe(1); + expect(scope).toBe(undefined); + }); + + it('should correctly register and clean-up consumers (several clean-up)', async () => { + const store = writable(0); + const doubleStore = computed(() => store() * 2); + expect(doubleStore()).toEqual(0); + await Promise.resolve(0); + expectCorrectlyCleanedUp(store); + expectCorrectlyCleanedUp(doubleStore); + const values: number[] = []; + const unsubscribe = doubleStore.subscribe((value) => values.push(value)); + expect(values).toEqual([0]); + await Promise.resolve(0); + store.set(1); + expect(values).toEqual([0, 2]); + unsubscribe(); + await Promise.resolve(0); + expectCorrectlyCleanedUp(store); + expectCorrectlyCleanedUp(doubleStore); + expect(doubleStore()).toEqual(2); + await Promise.resolve(0); + expectCorrectlyCleanedUp(store); + expectCorrectlyCleanedUp(doubleStore); + }); + + it('should correctly register and clean-up consumers (one clean-up at the end)', async () => { + const store = writable(0); + const doubleStore = computed(() => store() * 2); + expect(doubleStore()).toEqual(0); + const values: number[] = []; + const unsubscribe = doubleStore.subscribe((value) => values.push(value)); + expect(values).toEqual([0]); + await Promise.resolve(0); + store.set(1); + expect(values).toEqual([0, 2]); + unsubscribe(); + expect(doubleStore()).toEqual(2); + await Promise.resolve(0); + expectCorrectlyCleanedUp(store); + expectCorrectlyCleanedUp(doubleStore); + }); + + it('should correctly register and clean-up multiple levels of consumers', async () => { + const store = writable(0); + const doubleStore = computed(() => store() * 2); + const doubleDoubleStore = computed(() => doubleStore() * 2); + const doubleDoubleDoubleStore = computed(() => doubleDoubleStore() * 2); + expect(doubleDoubleDoubleStore()).toEqual(0); + await Promise.resolve(0); + expectCorrectlyCleanedUp(store); + expectCorrectlyCleanedUp(doubleStore); + expectCorrectlyCleanedUp(doubleDoubleStore); + expectCorrectlyCleanedUp(doubleDoubleDoubleStore); + }); }); }); diff --git a/src/index.ts b/src/index.ts index e76bbf8..6d93d28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,236 +5,50 @@ * @packageDocumentation */ -declare global { - interface SymbolConstructor { - readonly observable: symbol; - } -} - -/** - * Symbol used in {@link InteropObservable} allowing any object to expose an observable. - */ -export const symbolObservable: typeof Symbol.observable = - (typeof Symbol === 'function' && Symbol.observable) || ('@@observable' as any); - -const oldSubscription = Symbol(); - -/** - * A callback invoked when a store value changes. It is called with the latest value of a given store. - */ -export type SubscriberFunction = ((value: T) => void) & - Partial, 'next'>>; - -/** - * A partial {@link https://github.com/tc39/proposal-observable#api | observer} notified when a store value changes. A store will call the {@link SubscriberObject.next | next} method every time the store's state is changing. - */ -export interface SubscriberObject { - /** - * A store will call this method every time the store's state is changing. - */ - next: SubscriberFunction; - /** - * Unused, only declared for compatibility with rxjs. - */ - error?: any; - /** - * Unused, only declared for compatibility with rxjs. - */ - complete?: any; - /** - * A store will call this method when it knows that the value will be changed. - * A call to this method will be followed by a call to {@link SubscriberObject.next | next} or to {@link SubscriberObject.resume | resume}. - */ - pause: () => void; - /** - * A store will call this method if {@link SubscriberObject.pause | pause} was called previously - * and the value finally did not need to change. - */ - resume: () => void; - /** - * @internal - * Value returned from a previous call to subscribe, and corresponding to a subscription to resume. - * This subscription must no longer be active. The new subscriber will not be called synchronously if - * the value did not change compared to the last value received in this old subscription. - */ - [oldSubscription]?: Unsubscriber; -} - -interface PrivateSubscriberObject extends Omit, typeof oldSubscription> { - _value: T; - _valueIndex: number; - _paused: boolean; -} - -/** - * Expresses interest in store value changes over time. It can be either: - * - a callback function: {@link SubscriberFunction}; - * - a partial observer: {@link SubscriberObject}. - */ -export type Subscriber = SubscriberFunction | Partial> | null | undefined; - -/** - * A function to unsubscribe from value change notifications. - */ -export type UnsubscribeFunction = () => void; - -/** - * An object with the `unsubscribe` method. - * Subscribable stores might choose to return such object instead of directly returning {@link UnsubscribeFunction} from a subscription call. - */ -export interface UnsubscribeObject { - /** - * A method that acts as the {@link UnsubscribeFunction}. - */ - unsubscribe: UnsubscribeFunction; -} - -export type Unsubscriber = UnsubscribeObject | UnsubscribeFunction; - -/** - * Represents a store accepting registrations (subscribers) and "pushing" notifications on each and every store value change. - */ -export interface SubscribableStore { - /** - * A method that makes it possible to register "interest" in store value changes over time. - * It is called each and every time the store's value changes. - * - * A registered subscriber is notified synchronously with the latest store value. - * - * @param subscriber - a subscriber in a form of a {@link SubscriberFunction} or a {@link SubscriberObject}. Returns a {@link Unsubscriber} (function or object with the `unsubscribe` method) that can be used to unregister and stop receiving notifications of store value changes. - * @returns The {@link UnsubscribeFunction} or {@link UnsubscribeObject} that can be used to unsubscribe (stop state change notifications). - */ - subscribe(subscriber: Subscriber): Unsubscriber; -} - -/** - * An interface for interoperability between observable implementations. It only has to expose the `[Symbol.observable]` method that is supposed to return a subscribable store. - */ -export interface InteropObservable { - [Symbol.observable]: () => SubscribableStore; -} - -/** - * Valid types that can be considered as a store. - */ -export type StoreInput = SubscribableStore | InteropObservable; - -/** - * This interface augments the base {@link SubscribableStore} interface by requiring the return value of the subscribe method to be both a function and an object with the `unsubscribe` method. - * - * For {@link https://rxjs.dev/api/index/interface/InteropObservable | interoperability with rxjs}, it also implements the `[Symbol.observable]` method. - */ -export interface Readable extends SubscribableStore, InteropObservable { - subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject; - [Symbol.observable](): Readable; -} - -/** - * This interface augments the base {@link Readable} interface by adding the ability to call the store as a function to get its value. - */ -export interface ReadableSignal extends Readable { - /** - * Returns the value of the store. This is a shortcut for calling {@link get} with the store. - */ - (): T; -} - -/** - * A function that can be used to update store's value. This function is called with the current value and should return new store value. - */ -export type Updater = (value: T) => U; - -/** - * Builds on top of {@link Readable} and represents a store that can be manipulated from "outside": anyone with a reference to writable store can either update or completely replace state of a given store. - * - * @example - * - * ```typescript - * // reset counter's store value to 0 by using the {@link Writable.set} method - * counterStore.set(0); - * - * // increment counter's store value by using the {@link Writable.update} method - * counterStore.update(currentValue => currentValue + 1); - * ``` - */ -export interface Writable extends Readable { - /** - * Replaces store's state with the provided value. - * @param value - value to be used as the new state of a store. - */ - set(value: U): void; - - /** - * Updates store's state by using an {@link Updater} function. - * @param updater - a function that takes the current state as an argument and returns the new state. - */ - update(updater: Updater): void; -} - -/** - * Represents a store that implements both {@link ReadableSignal} and {@link Writable}. - * This is the type of objects returned by {@link writable}. - */ -export interface WritableSignal extends ReadableSignal, Writable {} - -const noop = () => {}; - -const noopUnsubscribe = () => {}; -noopUnsubscribe.unsubscribe = noopUnsubscribe; - -const bind = (object: T | null | undefined, fnName: keyof T) => { - const fn = object ? object[fnName] : null; - return typeof fn === 'function' ? fn.bind(object) : noop; -}; - -const toSubscriberObject = (subscriber: Subscriber): PrivateSubscriberObject => ({ - next: typeof subscriber === 'function' ? subscriber.bind(null) : bind(subscriber, 'next'), - pause: bind(subscriber, 'pause'), - resume: bind(subscriber, 'resume'), - _value: undefined as any, - _valueIndex: 0, - _paused: false, -}); - -const returnThis = function (this: T): T { - return this; -}; - -const normalizeUnsubscribe = ( - unsubscribe: Unsubscriber | void | null | undefined -): UnsubscribeFunction & UnsubscribeObject => { - if (!unsubscribe) { - return noopUnsubscribe; - } - if ((unsubscribe as any).unsubscribe === unsubscribe) { - return unsubscribe as any; - } - const res: any = - typeof unsubscribe === 'function' ? () => unsubscribe() : () => unsubscribe.unsubscribe(); - res.unsubscribe = res; - return res; -}; - -const normalizedSubscribe = new WeakSet['subscribe']>(); -const normalizeSubscribe = (store: SubscribableStore): Readable['subscribe'] => { - let res: Readable['subscribe'] = store.subscribe as any; - if (!normalizedSubscribe.has(res)) { - res = (...args: [Subscriber]) => normalizeUnsubscribe(store.subscribe(...args)); - normalizedSubscribe.add(res); - } - return res; -}; - -const getNormalizedSubscribe = (input: StoreInput) => { - const store = 'subscribe' in input ? input : input[symbolObservable](); - return normalizeSubscribe(store); -}; - -const getValue = (subscribe: Readable['subscribe']): T => { - let value: T; - subscribe((v) => (value = v))(); - return value!; -}; +import { equal } from './internal/equal'; +import { + exposeRawStore, + getRawStore, + rawStoreSymbol, + symbolObservable, +} from './internal/exposeRawStores'; +import { noop, RawStore } from './internal/store'; +import { RawStoreComputed } from './internal/storeComputed'; +import { RawStoreConst } from './internal/storeConst'; +import { + createOnUseArg, + RawStoreAsyncDerived, + RawStoreDerivedStore, + RawStoreSyncDerived, +} from './internal/storeDerived'; +import { RawStoreWithOnUse } from './internal/storeWithOnUse'; +import { untrack } from './internal/untrack'; +import type { + AsyncDeriveFn, + AsyncDeriveOptions, + OnUseFn, + Readable, + ReadableSignal, + StoreInput, + StoreOptions, + StoresInput, + StoresInputValues, + Subscriber, + SyncDeriveFn, + SyncDeriveOptions, + UnsubscribeFunction, + UnsubscribeObject, + Unsubscriber, + Updater, + Writable, + WritableSignal, +} from './types'; + +export { batch } from './internal/batch'; +export { equal } from './internal/equal'; +export { symbolObservable } from './internal/exposeRawStores'; +export { untrack } from './internal/untrack'; +export type * from './types'; /** * Returns a wrapper (for the given store) which only exposes the {@link ReadableSignal} interface. @@ -260,16 +74,11 @@ export function asReadable( store: StoreInput, extraProp?: U ): ReadableSignal & Omit> { - const subscribe = getNormalizedSubscribe(store); - const res = Object.assign(() => get(res), extraProp, { - subscribe, - [symbolObservable]: returnThis, - }); - return res; + return exposeRawStore(getRawStore(store), extraProp); } const defaultUpdate: any = function (this: Writable, updater: Updater) { - this.set(updater(untrack(() => get(this)))); + this.set(updater(untrack(() => this.get()))); }; /** @@ -322,87 +131,6 @@ export function asWritable( ) as any; } -const triggerUpdate = Symbol(); -const queueProcess = Symbol(); -let willProcessQueue = false; -const queue = new Set<{ [queueProcess](): void }>(); - -const MAX_STORE_PROCESSING_IN_QUEUE = 1000; -const checkIterations = (iterations: number) => { - if (iterations > MAX_STORE_PROCESSING_IN_QUEUE) { - throw new Error('reached maximum number of store changes in one shot'); - } -}; - -/** - * Batches multiple changes to stores while calling the provided function, - * preventing derived stores from updating until the function returns, - * to avoid unnecessary recomputations. - * - * @remarks - * - * If a store is updated multiple times in the provided function, existing - * subscribers of that store will only be called once when the provided - * function returns. - * - * Note that even though the computation of derived stores is delayed in most - * cases, some computations of derived stores will still occur inside - * the function provided to batch if a new subscriber is added to a store, because - * calling {@link SubscribableStore.subscribe | subscribe} always triggers a - * synchronous call of the subscriber and because tansu always provides up-to-date - * values when calling subscribers. Especially, calling {@link get} on a store will - * always return the correct up-to-date value and can trigger derived store - * intermediate computations, even inside batch. - * - * It is possible to have nested calls of batch, in which case only the first - * (outer) call has an effect, inner calls only call the provided function. - * - * @param fn - a function that can update stores. Its returned value is - * returned by the batch function. - * - * @example - * Using batch in the following example prevents logging the intermediate "Sherlock Lupin" value. - * - * ```typescript - * const firstName = writable('Arsène'); - * const lastName = writable('Lupin'); - * const fullName = derived([firstName, lastName], ([a, b]) => `${a} ${b}`); - * fullName.subscribe((name) => console.log(name)); // logs any change to fullName - * batch(() => { - * firstName.set('Sherlock'); - * lastName.set('Holmes'); - * }); - * ``` - */ -export const batch = (fn: () => T): T => { - const needsProcessQueue = !willProcessQueue; - if (needsProcessQueue) { - willProcessQueue = true; - } - try { - return fn(); - } finally { - if (needsProcessQueue) { - try { - const storePasses = new Map<{ [queueProcess](): void }, number>(); - for (const store of queue) { - const storeCount = storePasses.get(store) ?? 0; - checkIterations(storeCount); - storePasses.set(store, storeCount + 1); - queue.delete(store); - store[queueProcess](); - } - } finally { - queue.clear(); - willProcessQueue = false; - } - } - } -}; - -const defaultReactiveContext = (store: StoreInput) => getValue(getNormalizedSubscribe(store)); -let reactiveContext = defaultReactiveContext; - /** * A utility function to get the current value from a given store. * It works by subscribing to a store, capturing the value (synchronously) and unsubscribing just after. @@ -415,29 +143,7 @@ let reactiveContext = defaultReactiveContext; * console.log(get(myStore)); // logs 1 * ``` */ -export const get = (store: StoreInput): T => reactiveContext(store); - -const createEqualCache = (valueIndex: number): Record => ({ - [valueIndex]: true, // the subscriber already has the last value - [valueIndex - 1]: false, // the subscriber had the previous value, - // which is known to be different because equal is called in the set method - 0: false, // the subscriber never received any value -}); - -const skipEqualInSet = Symbol(); - -/** - * Default implementation of the equal function used by tansu when a store - * changes, to know if listeners need to be notified. - * Returns false if `a` is a function or an object, or if `a` and `b` - * are different according to `Object.is`. Otherwise, returns true. - * - * @param a - First value to compare. - * @param b - Second value to compare. - * @returns true if a and b are considered equal. - */ -export const equal = (a: T, b: T): boolean => - Object.is(a, b) && (!a || typeof a !== 'object') && typeof a !== 'function'; +export const get = (store: StoreInput): T => getRawStore(store).get(); /** * Base class that can be extended to easily create a custom {@link Readable} store. @@ -471,76 +177,24 @@ export const equal = (a: T, b: T): boolean => * ``` */ export abstract class Store implements Readable { - #subscribers = new Set>(); - #cleanupFn: null | UnsubscribeFunction = null; - #subscribersPaused = false; - #valueIndex = 1; - #value: T; - #equalCache = createEqualCache(1); - #oldSubscriptions = new WeakMap>(); - - private [skipEqualInSet] = false; - /** * * @param value - Initial value of the store */ constructor(value: T) { - this.#value = value; - } - - #start() { - this.#cleanupFn = normalizeUnsubscribe(this.onUse()); - } - - #stop() { - const cleanupFn = this.#cleanupFn; - if (cleanupFn) { - this.#cleanupFn = null; - cleanupFn(); - } - } - - private [queueProcess](): void { - const valueIndex = this.#valueIndex; - for (const subscriber of [...this.#subscribers]) { - if (this.#subscribersPaused || this.#valueIndex !== valueIndex) { - // the value of the store can change while notifying subscribers - // in that case, let's just stop notifying subscribers - // they will be called later through another queueProcess call - // with the correct final value and in the right order - return; - } - if (subscriber._valueIndex === 0) { - // ignore subscribers which were not yet called synchronously - continue; - } - this.#notifySubscriber(subscriber); + let rawStore; + if (value instanceof RawStore) { + rawStore = value; + } else { + const onUse = this.onUse; + rawStore = onUse ? new RawStoreWithOnUse(value, onUse.bind(this)) : new RawStore(value); + rawStore.equalFn = (a, b) => this.equal(a, b); } + this[rawStoreSymbol] = rawStore; } /** @internal */ - protected [triggerUpdate](): void {} - - #notifySubscriber(subscriber: PrivateSubscriberObject): void { - const equalCache = this.#equalCache; - const valueIndex = this.#valueIndex; - const value = this.#value; - let equal = equalCache[subscriber._valueIndex]; - if (equal == null) { - equal = !!this.equal(subscriber._value, value); - equalCache[subscriber._valueIndex] = equal; - } - subscriber._valueIndex = valueIndex; - if (!equal) { - subscriber._value = value; - subscriber._paused = false; - subscriber.next(value); - } else if (!this.#subscribersPaused && subscriber._paused) { - subscriber._paused = false; - subscriber.resume(); - } - } + [rawStoreSymbol]: RawStore; /** * Compares two values and returns true if they are equal. @@ -585,54 +239,6 @@ export abstract class Store implements Readable { return !equal(a, b); } - /** - * Puts the store in the paused state, which means it will soon update its value. - * - * @remarks - * - * The paused state prevents derived or computed stores (both direct and transitive) from recomputing their value - * using the current value of this store. - * - * There are two ways to put a store back into its normal state: calling {@link Store.set | set} to set a new - * value or calling {@link Store.resumeSubscribers | resumeSubscribers} to declare that finally the value does not need to be - * changed. - * - * Note that a store should not stay in the paused state for a long time, and most of the time - * it is not needed to call pauseSubscribers or resumeSubscribers manually. - * - */ - protected pauseSubscribers(): void { - if (!this.#subscribersPaused) { - this.#subscribersPaused = true; - queue.delete(this as any); - for (const subscriber of [...this.#subscribers]) { - if (subscriber._valueIndex === 0 || subscriber._paused) { - // ignore subscribers which were not yet called synchronously or are already paused - continue; - } - subscriber._paused = true; - subscriber.pause(); - } - } - } - - /** - * Puts the store back to the normal state without changing its value, if it was in the paused state - * (cf {@link Store.pauseSubscribers | pauseSubscribers}). - * - * @remarks - * - * Does nothing if the store was not in the paused state. - */ - protected resumeSubscribers(): void { - if (this.#subscribersPaused) { - this.#subscribersPaused = false; - batch(() => { - queue.add(this as any); - }); - } - } - /** * Replaces store's state with the provided value. * Equivalent of {@link Writable.set}, but internal to the store. @@ -640,18 +246,11 @@ export abstract class Store implements Readable { * @param value - value to be used as the new state of a store. */ protected set(value: T): void { - const skipEqual = this[skipEqualInSet]; - if (skipEqual || !this.equal(this.#value, value)) { - const valueIndex = this.#valueIndex + 1; - this.#valueIndex = valueIndex; - this.#value = value; - this.#equalCache = createEqualCache(valueIndex); - if (skipEqual) { - delete this.#equalCache[valueIndex - 1]; - } - this.pauseSubscribers(); - } - this.resumeSubscribers(); + getRawStore(this).set(value); + } + + get(): T { + return getRawStore(this).get(); } /** @@ -661,7 +260,7 @@ export abstract class Store implements Readable { * @param updater - a function that takes the current state as an argument and returns the new state. */ protected update(updater: Updater): void { - this.set(updater(this.#value)); + getRawStore(this).update(updater); } /** @@ -688,50 +287,14 @@ export abstract class Store implements Readable { * unsubscribe2(); // logs 'All subscribers are gone...' * ``` */ - protected onUse(): Unsubscriber | void {} + protected onUse?(): Unsubscriber | void; /** * Default Implementation of the {@link SubscribableStore.subscribe}, not meant to be overridden. * @param subscriber - see {@link SubscribableStore.subscribe} */ subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject { - const subscriberObject = toSubscriberObject(subscriber); - const oldSubscriptionParam = subscriber?.[oldSubscription]; - if (oldSubscriptionParam) { - const oldSubscriberObject = this.#oldSubscriptions.get(oldSubscriptionParam); - if (oldSubscriberObject) { - subscriberObject._value = oldSubscriberObject._value; - subscriberObject._valueIndex = oldSubscriberObject._valueIndex; - } - } - this.#subscribers.add(subscriberObject); - batch(() => { - if (this.#subscribers.size == 1) { - this.#start(); - } else { - this[triggerUpdate](); - } - }); - this.#notifySubscriber(subscriberObject); - - const unsubscribe = () => { - const removed = this.#subscribers.delete(subscriberObject); - subscriberObject.next = noop; - subscriberObject.pause = noop; - subscriberObject.resume = noop; - if (removed) { - this.#oldSubscriptions.set(unsubscribe, subscriberObject); - if (this.#subscribers.size === 0) { - this.#stop(); - } - } - }; - (unsubscribe as any)[triggerUpdate] = () => { - this[triggerUpdate](); - this.#notifySubscriber(subscriberObject); - }; - unsubscribe.unsubscribe = unsubscribe; - return unsubscribe; + return getRawStore(this).subscribe(subscriber); } [symbolObservable](): this { @@ -739,124 +302,23 @@ export abstract class Store implements Readable { } } -export interface OnUseArgument { - (value: T): void; - set: (value: T) => void; - update: (updater: Updater) => void; -} - -/** - * Type of a function that is called when the number of subscribers changes from 0 to 1 - * (but not called when the number of subscribers changes from 1 to 2, ...). - * - * If it returns a function, that function will be called when the number of subscribers changes from 1 to 0. - */ -export type OnUseFn = (arg: OnUseArgument) => void | Unsubscriber; - -/** - * Store options that can be passed to {@link readable} or {@link writable}. - */ -export interface StoreOptions { - /** - * A function that is called when the number of subscribers changes from 0 to 1 - * (but not called when the number of subscribers changes from 1 to 2, ...). - * If it returns a function, that function will be called when the number of subscribers changes from 1 to 0. - */ - onUse?: OnUseFn; - - /** - * Custom function to compare two values, that should return true if they - * are equal. - * - * It is called when setting a new value to avoid doing anything - * (such as notifying subscribers) if the value did not change. - * - * @remarks - * The default logic (when this option is not present) is to return false - * if `a` is a function or an object, or if `a` and `b` are different - * according to `Object.is`. - * - * {@link StoreOptions.equal|equal} takes precedence over {@link StoreOptions.notEqual|notEqual} if both - * are defined. - * - * @param a - First value to compare. - * @param b - Second value to compare. - * @returns true if a and b are considered equal. - */ - equal?: (a: T, b: T) => boolean; - - /** - * Custom function to compare two values, that should return true if they - * are different. - * - * It is called when setting a new value to avoid doing anything - * (such as notifying subscribers) if the value did not change. - * - * @remarks - * The default logic (when this option is not present) is to return true - * if `a` is a function or an object, or if `a` and `b` are different - * according to `Object.is`. - * - * {@link StoreOptions.equal} takes precedence over {@link StoreOptions.notEqual|notEqual} if both - * are defined. - * - * @deprecated Use {@link StoreOptions.equal} instead - * @param a - First value to compare. - * @param b - Second value to compare. - * @returns true if a and b are considered different. - */ - notEqual?: (a: T, b: T) => boolean; -} +const createStoreWithOnUse = (initValue: T, onUse: OnUseFn) => { + const store: RawStoreWithOnUse = new RawStoreWithOnUse(initValue, () => onUse(setFn)); + const setFn = createOnUseArg(store); + return store; +}; -/** - * A convenience function to create an optimized constant store (i.e. which never changes - * its value). It does not keep track of its subscribers. - * @param value - value of the store, which will never change - */ -function constStore(value: T): ReadableSignal { - const subscribe = (subscriber: Subscriber) => { - if (!subscriber?.[oldSubscription]) { - toSubscriberObject(subscriber).next(value); +const applyStoreOptions = >( + store: S, + options?: Omit, 'onUse'> +): S => { + if (options) { + const { equal, notEqual } = options; + if (equal) { + store.equalFn = equal; + } else if (notEqual) { + store.equalFn = (a: T, b: T) => !notEqual(a, b); } - return noopUnsubscribe; - }; - normalizedSubscribe.add(subscribe); - return Object.assign(() => value, { subscribe, [symbolObservable]: returnThis }); -} - -class WritableStore extends Store implements Writable { - constructor(value: T) { - super(value); - } - - override set(value: T): void { - super.set(value); - } - - override update(updater: Updater) { - super.update(updater); - } -} - -const applyStoreOptions = >(store: S, options: StoreOptions): S => { - const { onUse, equal, notEqual } = options; - if (onUse) { - (store as any).onUse = function (this: Store) { - const setFn = (v: T) => this.set(v); - setFn.set = setFn; - setFn.update = (updater: Updater) => this.update(updater); - return onUse(setFn); - }; - } - if (equal) { - (store as any).equal = function (this: Store, a: T, b: T) { - return equal(a, b); - }; - } - if (notEqual) { - (store as any).notEqual = function (this: Store, a: T, b: T) { - return notEqual(a, b); - }; } return store; }; @@ -883,18 +345,16 @@ const applyStoreOptions = >(store: S, options: StoreOption * }); * ``` */ -export function readable( - value: T, - options: StoreOptions | OnUseFn = {} -): ReadableSignal { +export function readable(value: T, options?: StoreOptions | OnUseFn): ReadableSignal { if (typeof options === 'function') { options = { onUse: options }; } - if (!options.onUse) { - // special optimized case - return constStore(value); - } - return asReadable(applyStoreOptions(new WritableStore(value), options)); + const onUse = options?.onUse; + return exposeRawStore( + onUse + ? applyStoreOptions(createStoreWithOnUse(value, onUse), options) + : new RawStoreConst(value) + ); } /** @@ -915,52 +375,21 @@ export function readable( * x.set(0); // reset back to the default value * ``` */ -export function writable( - value: T, - options: StoreOptions | OnUseFn = {} -): WritableSignal { +export function writable(value: T, options?: StoreOptions | OnUseFn): WritableSignal { if (typeof options === 'function') { options = { onUse: options }; } - const store = applyStoreOptions(new WritableStore(value), options); - return asReadable, 'set' | 'update'>>(store, { - set: store.set.bind(store), - update: store.update.bind(store), - }); + const onUse = options?.onUse; + const store = applyStoreOptions( + onUse ? createStoreWithOnUse(value, onUse) : new RawStore(value), + options + ); + const res = exposeRawStore(store) as any; + res.set = store.set.bind(store); + res.update = store.update.bind(store); + return res; } -/** - * Either a single {@link StoreInput} or a read-only array of at least one {@link StoreInput}. - */ -export type StoresInput = StoreInput | readonly [StoreInput, ...StoreInput[]]; - -/** - * Extracts the types of the values of the stores from a type extending {@link StoresInput}. - * - * @remarks - * - * If the type given as a parameter is a single {@link StoreInput}, the type of the value - * of that {@link StoreInput} is returned. - * - * If the type given as a parameter is one of an array of {@link StoreInput}, the returned type - * is the type of an array containing the value of each store in the same order. - */ -export type StoresInputValues = - S extends StoreInput - ? T - : { [K in keyof S]: S[K] extends StoreInput ? T : never }; - -export type SyncDeriveFn = (values: StoresInputValues) => T; -export interface SyncDeriveOptions extends Omit, 'onUse'> { - derive: SyncDeriveFn; -} -export type AsyncDeriveFn = ( - values: StoresInputValues, - set: OnUseArgument -) => Unsubscriber | void; -export interface AsyncDeriveOptions extends Omit, 'onUse'> { - derive: AsyncDeriveFn; -} type DeriveFn = SyncDeriveFn | AsyncDeriveFn; interface DeriveOptions extends Omit, 'onUse'> { derive: DeriveFn; @@ -969,109 +398,14 @@ function isSyncDeriveFn(fn: DeriveFn): fn is SyncDeriveFn { return fn.length <= 1; } -const callFn = (fn: () => void) => fn(); - export abstract class DerivedStore extends Store { - readonly #isArray: boolean; - readonly #storesSubscribeFn: Readable['subscribe'][]; - #pending = 0; - constructor(stores: S, initialValue: T) { - super(initialValue); - const isArray = Array.isArray(stores); - this.#isArray = isArray; - this.#storesSubscribeFn = (isArray ? [...stores] : [stores]).map(getNormalizedSubscribe); - } - - protected override resumeSubscribers(): void { - if (!this.#pending) { - // only resume subscribers if we know that the values of the stores with which - // the derived function was called were the correct ones - super.resumeSubscribers(); - } - } - - protected override onUse(): Unsubscriber | void { - let initDone = false; - let changed = 0; - - const isArray = this.#isArray; - const storesSubscribeFn = this.#storesSubscribeFn; - const dependantValues = new Array(storesSubscribeFn.length); - - let cleanupFn: null | UnsubscribeFunction = null; - - const callCleanup = () => { - const fn = cleanupFn; - if (fn) { - cleanupFn = null; - fn(); - } - }; - - const callDerive = (setInitDone = false) => { - if (setInitDone) { - initDone = true; - } - if (initDone && !this.#pending) { - if (changed) { - changed = 0; - callCleanup(); - cleanupFn = normalizeUnsubscribe( - this.derive(isArray ? dependantValues : dependantValues[0]) - ); - } - this.resumeSubscribers(); - } - }; - - const unsubscribers = storesSubscribeFn.map((subscribe, idx) => { - const subscriber = (v: any) => { - dependantValues[idx] = v; - changed |= 1 << idx; - this.#pending &= ~(1 << idx); - callDerive(); - }; - subscriber.next = subscriber; - subscriber.pause = () => { - this.#pending |= 1 << idx; - this.pauseSubscribers(); - }; - subscriber.resume = () => { - this.#pending &= ~(1 << idx); - callDerive(); - }; - return subscribe(subscriber); - }); - - const triggerSubscriberPendingUpdate = (unsubscriber: any, idx: number) => { - if (this.#pending & (1 << idx)) { - unsubscriber[triggerUpdate]?.(); - } - }; - this[triggerUpdate] = () => { - let iterations = 0; - while (this.#pending) { - checkIterations(++iterations); - initDone = false; - unsubscribers.forEach(triggerSubscriberPendingUpdate); - if (this.#pending) { - // safety check: if pending is not 0 after calling triggerUpdate, - // it will never be and this is an endless loop - break; - } - callDerive(true); - } - }; - callDerive(true); - this[triggerUpdate](); - return () => { - this[triggerUpdate] = noop; - callCleanup(); - unsubscribers.forEach(callFn); - }; + const rawStore = new RawStoreDerivedStore(stores, initialValue, (values) => + this.derive(values) + ); + rawStore.equalFn = (a, b) => this.equal(a, b); + super(rawStore as any); } - protected abstract derive(values: StoresInputValues): Unsubscriber | void; } @@ -1138,224 +472,12 @@ export function derived( options = { derive: options }; } const { derive, ...opts } = options; - const Derived = isSyncDeriveFn(derive) - ? class extends DerivedStore { - constructor(stores: S, initialValue: T) { - super(stores, initialValue); - this[skipEqualInSet] = true; // skip call to equal in set until the first value is set - } - protected override derive(values: StoresInputValues) { - this.set(derive(values)); - this[skipEqualInSet] = false; - } - } - : class extends DerivedStore { - protected override derive(values: StoresInputValues) { - const setFn = (v: T) => this.set(v); - setFn.set = setFn; - setFn.update = (updater: Updater) => this.update(updater); - return derive(values, setFn); - } - }; - return asReadable( - applyStoreOptions(new Derived(stores, initialValue as any), { - ...opts, - onUse: undefined /* setting onUse is not allowed from derived */, - }) + const Derived = isSyncDeriveFn(derive) ? RawStoreSyncDerived : RawStoreAsyncDerived; + return exposeRawStore( + applyStoreOptions(new Derived(stores as any, initialValue as any, derive as any), opts) ); } -/** - * Stops the tracking of dependencies made by {@link computed} and calls the provided function. - * After the function returns, the tracking of dependencies continues as before. - * - * @param fn - function to be called - * @returns the value returned by the given function - */ -export const untrack = (fn: () => T): T => { - const previousReactiveContext = reactiveContext; - try { - reactiveContext = defaultReactiveContext; - return fn(); - } finally { - reactiveContext = previousReactiveContext; - } -}; - -interface ComputedStoreSubscription { - versionIndex: number; - resubscribe: () => void; - unsubscribe: UnsubscribeFunction; - pending: boolean; - usedValueIndex: number; - valueIndex: number; - value: T; -} - -const callUnsubscribe = ({ unsubscribe }: ComputedStoreSubscription) => unsubscribe(); -const callResubscribe = ({ resubscribe }: ComputedStoreSubscription) => resubscribe(); - -abstract class ComputedStore extends Store { - #computing = false; - #skipCallCompute = false; - #versionIndex = 0; - #subscriptions = new Map, ComputedStoreSubscription>(); - - #reactiveContext = (storeInput: StoreInput): U => - untrack(() => this.#getSubscriptionValue(storeInput)); - - constructor() { - super(undefined as T); - this[skipEqualInSet] = true; // skip call to equal in set until the first value is set - } - - #createSubscription(subscribe: Readable['subscribe']) { - const res: ComputedStoreSubscription = { - versionIndex: this.#versionIndex, - unsubscribe: noop, - resubscribe: noop, - pending: false, - usedValueIndex: 0, - value: undefined as T, - valueIndex: 0, - }; - const subscriber: SubscriberFunction & Partial> = (value: T) => { - res.value = value; - res.valueIndex++; - res.pending = false; - if (!this.#skipCallCompute && !this.#isPending()) { - batch(() => this.#callCompute()); - } - }; - subscriber.next = subscriber; - subscriber.pause = () => { - res.pending = true; - this.pauseSubscribers(); - }; - subscriber.resume = () => { - res.pending = false; - if (!this.#skipCallCompute && !this.#isPending()) { - batch(() => this.#callCompute()); - } - }; - res.resubscribe = () => { - res.unsubscribe = subscribe(subscriber); - subscriber[oldSubscription] = res.unsubscribe; - }; - res.resubscribe(); - return res; - } - - #getSubscriptionValue(storeInput: StoreInput) { - let res = this.#subscriptions.get(storeInput); - if (res) { - res.versionIndex = this.#versionIndex; - (res.unsubscribe as any)[triggerUpdate]?.(); - } else { - res = this.#createSubscription(getNormalizedSubscribe(storeInput)); - this.#subscriptions.set(storeInput, res); - } - res.usedValueIndex = res.valueIndex; - return res.value; - } - - #callCompute(resubscribe = false) { - this.#computing = true; - this.#skipCallCompute = true; - try { - if (this.#versionIndex > 0) { - if (resubscribe) { - this.#subscriptions.forEach(callResubscribe); - } - if (!this.#hasChange()) { - this.resumeSubscribers(); - return; - } - } - this.#versionIndex++; - const versionIndex = this.#versionIndex; - const previousReactiveContext = reactiveContext; - let value: T; - try { - reactiveContext = this.#reactiveContext; - value = this.compute(); - } finally { - reactiveContext = previousReactiveContext; - } - this.set(value); - this[skipEqualInSet] = false; - for (const [store, info] of this.#subscriptions) { - if (info.versionIndex !== versionIndex) { - this.#subscriptions.delete(store); - info.unsubscribe(); - } - } - } finally { - this.#skipCallCompute = false; - this.#computing = false; - } - } - - #isPending() { - for (const [, { pending }] of this.#subscriptions) { - if (pending) { - return true; - } - } - return false; - } - - #hasChange() { - for (const [, { valueIndex, usedValueIndex }] of this.#subscriptions) { - if (valueIndex != usedValueIndex) { - return true; - } - } - return false; - } - - protected override resumeSubscribers(): void { - if (!this.#isPending()) { - super.resumeSubscribers(); - } - } - - /** @internal */ - protected override [triggerUpdate](): void { - if (this.#computing) { - throw new Error('recursive computed'); - } - let iterations = 0; - while (this.#isPending()) { - checkIterations(++iterations); - this.#skipCallCompute = true; - try { - for (const [, { pending, unsubscribe }] of this.#subscriptions) { - if (pending) { - (unsubscribe as any)[triggerUpdate]?.(); - } - } - } finally { - this.#skipCallCompute = false; - } - if (this.#isPending()) { - // safety check: if it is still pending after calling triggerUpdate, - // it will always be and this is an endless loop - break; - } - this.#callCompute(); - } - } - - protected abstract compute(): T; - - protected override onUse(): Unsubscriber { - this.#callCompute(true); - this[triggerUpdate](); - return () => this.#subscriptions.forEach(callUnsubscribe); - } -} - /** * Creates a store whose value is computed by the provided function. * @@ -1380,17 +502,7 @@ abstract class ComputedStore extends Store { */ export function computed( fn: () => T, - options: Omit, 'onUse'> = {} + options?: Omit, 'onUse'> ): ReadableSignal { - const Computed = class extends ComputedStore { - protected override compute(): T { - return fn(); - } - }; - return asReadable( - applyStoreOptions(new Computed(), { - ...options, - onUse: undefined /* setting onUse is not allowed from computed */, - }) - ); + return exposeRawStore(applyStoreOptions(new RawStoreComputed(fn), options)); } diff --git a/src/internal/batch.ts b/src/internal/batch.ts new file mode 100644 index 0000000..cee04ba --- /dev/null +++ b/src/internal/batch.ts @@ -0,0 +1,76 @@ +import type { SubscribeConsumer } from './store'; + +export const subscribersQueue: SubscribeConsumer[] = []; +let willProcessQueue = false; + +/** + * Batches multiple changes to stores while calling the provided function, + * preventing derived stores from updating until the function returns, + * to avoid unnecessary recomputations. + * + * @remarks + * + * If a store is updated multiple times in the provided function, existing + * subscribers of that store will only be called once when the provided + * function returns. + * + * Note that even though the computation of derived stores is delayed in most + * cases, some computations of derived stores will still occur inside + * the function provided to batch if a new subscriber is added to a store, because + * calling {@link SubscribableStore.subscribe | subscribe} always triggers a + * synchronous call of the subscriber and because tansu always provides up-to-date + * values when calling subscribers. Especially, calling {@link get} on a store will + * always return the correct up-to-date value and can trigger derived store + * intermediate computations, even inside batch. + * + * It is possible to have nested calls of batch, in which case only the first + * (outer) call has an effect, inner calls only call the provided function. + * + * @param fn - a function that can update stores. Its returned value is + * returned by the batch function. + * + * @example + * Using batch in the following example prevents logging the intermediate "Sherlock Lupin" value. + * + * ```typescript + * const firstName = writable('Arsène'); + * const lastName = writable('Lupin'); + * const fullName = derived([firstName, lastName], ([a, b]) => `${a} ${b}`); + * fullName.subscribe((name) => console.log(name)); // logs any change to fullName + * batch(() => { + * firstName.set('Sherlock'); + * lastName.set('Holmes'); + * }); + * ``` + */ +export const batch = (fn: () => T): T => { + const needsProcessQueue = !willProcessQueue; + willProcessQueue = true; + let success = true; + let res; + let error; + try { + res = fn(); + } finally { + if (needsProcessQueue) { + while (subscribersQueue.length > 0) { + const consumer = subscribersQueue.shift()!; + try { + consumer.notify(); + } catch (e) { + // an error in one consumer should not impact others + if (success) { + // will throw the first error + success = false; + error = e; + } + } + } + willProcessQueue = false; + } + } + if (success) { + return res; + } + throw error; +}; diff --git a/src/internal/equal.ts b/src/internal/equal.ts new file mode 100644 index 0000000..9a63fc3 --- /dev/null +++ b/src/internal/equal.ts @@ -0,0 +1,12 @@ +/** + * Default implementation of the equal function used by tansu when a store + * changes, to know if listeners need to be notified. + * Returns false if `a` is a function or an object, or if `a` and `b` + * are different according to `Object.is`. Otherwise, returns true. + * + * @param a - First value to compare. + * @param b - Second value to compare. + * @returns true if a and b are considered equal. + */ +export const equal = (a: T, b: T): boolean => + Object.is(a, b) && (!a || typeof a !== 'object') && typeof a !== 'function'; diff --git a/src/internal/exposeRawStores.ts b/src/internal/exposeRawStores.ts new file mode 100644 index 0000000..4c96854 --- /dev/null +++ b/src/internal/exposeRawStores.ts @@ -0,0 +1,48 @@ +import type { Readable, ReadableSignal, StoreInput } from '../types'; +import type { RawStore } from './store'; +import { RawSubscribableWrapper } from './storeSubscribable'; + +/** + * Symbol used in {@link InteropObservable} allowing any object to expose an observable. + */ +export const symbolObservable: typeof Symbol.observable = + (typeof Symbol === 'function' && Symbol.observable) || ('@@observable' as any); + +const returnThis = function (this: T): T { + return this; +}; + +export const rawStoreSymbol = Symbol(); +const rawStoreMap = new WeakMap, RawStore>(); + +export const getRawStore = (storeInput: StoreInput): RawStore => { + const rawStore = (storeInput as any)[rawStoreSymbol]; + if (rawStore) { + return rawStore; + } + let res = rawStoreMap.get(storeInput); + if (!res) { + let subscribable = storeInput; + if (!('subscribe' in subscribable)) { + subscribable = subscribable[symbolObservable](); + } + res = new RawSubscribableWrapper(subscribable); + rawStoreMap.set(storeInput, res); + } + return res; +}; + +export const exposeRawStore = ( + rawStore: RawStore, + extraProp?: U +): ReadableSignal & Omit> => { + const get = rawStore.get.bind(rawStore) as any; + if (extraProp) { + Object.assign(get, extraProp); + } + get.get = get; + get.subscribe = rawStore.subscribe.bind(rawStore); + get[symbolObservable] = returnThis; + get[rawStoreSymbol] = rawStore; + return get; +}; diff --git a/src/internal/store.ts b/src/internal/store.ts new file mode 100644 index 0000000..f3d5c33 --- /dev/null +++ b/src/internal/store.ts @@ -0,0 +1,259 @@ +import type { + SignalStore, + SubscribableStore, + Subscriber, + SubscriberObject, + UnsubscribeFunction, + UnsubscribeObject, + Updater, +} from '../types'; +import { batch, subscribersQueue } from './batch'; +import { equal } from './equal'; +import { activeConsumer } from './untrack'; + +export let notificationPhase = false; + +export const checkNotInNotificationPhase = (): void => { + if (notificationPhase) { + throw new Error('Reading or writing a signal is forbidden during the notification phase.'); + } +}; + +export let epoch = 0; + +export interface Consumer { + markDirty(): void; +} + +export interface ProducerConsumerLink { + value: T; + version: number; + producer: RawStore; + indexInProducer: number; + consumer: Consumer | null; + skipMarkDirty: boolean; +} + +export const updateLinkProducerValue = (link: ProducerConsumerLink): void => { + try { + link.skipMarkDirty = true; + link.producer.updateValue(); + } finally { + link.skipMarkDirty = false; + } +}; + +export const isLinkUpToDate = (link: ProducerConsumerLink): boolean => { + const producer = link.producer; + if (link.version === producer.version) { + return true; + } + if (link.version === producer.version - 1 || link.version < 0) { + return false; + } + if (!producer.equalCache) { + producer.equalCache = {}; + } + let res = producer.equalCache[link.version]; + if (res === undefined) { + res = producer.equal(link.value, producer.value); + producer.equalCache[link.version] = res; + } + return res; +}; + +export const updateLink = (link: ProducerConsumerLink): T => { + const producer = link.producer; + link.value = producer.value; + link.version = producer.version; + return producer.readValue(); +}; + +export const enum RawStoreFlags { + NONE = 0, + // the following flags are used in RawStoreTrackingUsage and derived classes + HAS_VISIBLE_ONUSE = 1, + START_USE_CALLED = 1 << 1, + INSIDE_GET = 1 << 2, + FLUSH_PLANNED = 1 << 3, + // the following flags are used in RawStoreComputedOrDerived and derived classes + COMPUTING = 1 << 4, + DIRTY = 1 << 5, +} + +export class RawStore implements SignalStore, SubscribableStore { + constructor(public value: T) {} + flags = RawStoreFlags.NONE; + version = 0; + equalFn = equal; + equalCache: Record | null = null; + consumerLinks: null | ProducerConsumerLink[] = null; + + newLink(consumer: Consumer | null): ProducerConsumerLink { + return { + version: -1, + value: undefined as any, + producer: this, + indexInProducer: 0, + consumer, + skipMarkDirty: false, + }; + } + + registerConsumer(link: ProducerConsumerLink): ProducerConsumerLink { + let consumerLinks = this.consumerLinks; + if (!consumerLinks) { + consumerLinks = []; + this.consumerLinks = consumerLinks; + } + const indexInProducer = consumerLinks.length; + link.indexInProducer = indexInProducer; + consumerLinks[indexInProducer] = link; + return link; + } + + unregisterConsumer(link: ProducerConsumerLink): void { + const consumerLinks = this.consumerLinks; + const index = link.indexInProducer; + /* v8 ignore next 3 */ + if (consumerLinks?.[index] !== link) { + throw new Error('assert failed: invalid indexInProducer'); + } + // swap with the last item to avoid shifting the array + const lastConsumerLink = consumerLinks.pop()!; + const isLast = link === lastConsumerLink; + if (!isLast) { + consumerLinks[index] = lastConsumerLink; + lastConsumerLink.indexInProducer = index; + } else if (index === 0) { + this.checkUnused(); + } + } + + checkUnused(): void {} + updateValue(): void {} + + equal(a: T, b: T): boolean { + const equalFn = this.equalFn; + return equalFn(a, b); + } + + protected increaseEpoch(): void { + epoch++; + this.markConsumersDirty(); + } + + set(newValue: T): void { + checkNotInNotificationPhase(); + const same = this.equal(this.value, newValue); + if (!same) { + batch(() => { + this.value = newValue; + this.version++; + this.equalCache = null; + this.increaseEpoch(); + }); + } + } + + update(updater: Updater): void { + this.set(updater(this.value)); + } + + markConsumersDirty(): void { + const prevNotificationPhase = notificationPhase; + notificationPhase = true; + try { + const consumerLinks = this.consumerLinks; + if (consumerLinks) { + for (let i = 0, l = consumerLinks.length; i < l; i++) { + const link = consumerLinks[i]; + if (link.skipMarkDirty) continue; + link.consumer?.markDirty?.(); + } + } + } finally { + notificationPhase = prevNotificationPhase; + } + } + + get(): T { + checkNotInNotificationPhase(); + if (activeConsumer) { + return activeConsumer.addProducer(this); + } else { + return this.readValue(); + } + } + + readValue(): T { + return this.value; + } + + subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject { + checkNotInNotificationPhase(); + const subscription = new SubscribeConsumer(this, subscriber); + const unsubscriber = () => subscription.unsubscribe(); + unsubscriber.unsubscribe = unsubscriber; + return unsubscriber; + } +} + +export const noop = (): void => {}; + +const bind = (object: T | null | undefined, fnName: keyof T) => { + const fn = object ? object[fnName] : null; + return typeof fn === 'function' ? fn.bind(object) : noop; +}; + +const noopSubscriber: SubscriberObject = { + next: noop, + pause: noop, + resume: noop, +}; + +export const toSubscriberObject = (subscriber: Subscriber): SubscriberObject => ({ + next: typeof subscriber === 'function' ? subscriber.bind(null) : bind(subscriber, 'next'), + pause: bind(subscriber, 'pause'), + resume: bind(subscriber, 'resume'), +}); + +export class SubscribeConsumer implements Consumer { + link: ProducerConsumerLink; + subscriber: SubscriberObject; + dirtyCount = 1; + constructor(producer: RawStore, subscriber: Subscriber) { + this.subscriber = toSubscriberObject(subscriber); + this.link = producer.registerConsumer(producer.newLink(this)); + this.notify(); + } + + unsubscribe(): void { + if (this.subscriber !== noopSubscriber) { + this.subscriber = noopSubscriber; + this.link.producer.unregisterConsumer(this.link); + } + } + + markDirty(): void { + this.dirtyCount++; + subscribersQueue.push(this); + if (this.dirtyCount === 1) { + this.subscriber.pause(); + } + } + + notify(): void { + this.dirtyCount--; + if (this.dirtyCount === 0 && this.subscriber !== noopSubscriber) { + updateLinkProducerValue(this.link); + if (isLinkUpToDate(this.link)) { + this.subscriber.resume(); + } else { + // note that the following line can throw + const value = updateLink(this.link); + this.subscriber.next(value); + } + } + } +} diff --git a/src/internal/storeComputed.ts b/src/internal/storeComputed.ts new file mode 100644 index 0000000..55d8b62 --- /dev/null +++ b/src/internal/storeComputed.ts @@ -0,0 +1,142 @@ +import type { Consumer, ProducerConsumerLink, RawStore } from './store'; +import { + epoch, + isLinkUpToDate, + notificationPhase, + RawStoreFlags, + updateLink, + updateLinkProducerValue, +} from './store'; +import { + COMPUTED_ERRORED, + COMPUTED_UNSET, + RawStoreComputedOrDerived, +} from './storeComputedOrDerived'; +import { activeConsumer, setActiveConsumer, type ActiveConsumer } from './untrack'; + +export class RawStoreComputed + extends RawStoreComputedOrDerived + implements Consumer, ActiveConsumer +{ + producerIndex = 0; + producerLinks: ProducerConsumerLink[] | null = null; + epoch = -1; + + constructor(public computeFn: () => T) { + super(COMPUTED_UNSET); + } + + override increaseEpoch(): void { + // do nothing + } + + override updateValue(): void { + const flags = this.flags; + if (flags & RawStoreFlags.START_USE_CALLED && this.epoch === epoch) { + return; + } + super.updateValue(); + this.epoch = epoch; + } + + override get(): T { + if ( + !activeConsumer && + !notificationPhase && + this.epoch === epoch && + (!(this.flags & RawStoreFlags.HAS_VISIBLE_ONUSE) || + this.flags & RawStoreFlags.START_USE_CALLED) + ) { + return this.readValue(); + } + return super.get(); + } + + addProducer(producer: RawStore): U { + let producerLinks = this.producerLinks; + if (!producerLinks) { + producerLinks = []; + this.producerLinks = producerLinks; + } + const producerIndex = this.producerIndex; + let link = producerLinks[producerIndex]; + if (link?.producer !== producer) { + if (link) { + const endIndex = Math.max(producerIndex + 1, producerLinks.length); + producerLinks[endIndex] = link; // push the existing link at the end (to be removed later) + } + link = producer.registerConsumer(producer.newLink(this)); + } + producerLinks[producerIndex] = link; + this.producerIndex = producerIndex + 1; + updateLinkProducerValue(link); + if (producer.flags & RawStoreFlags.HAS_VISIBLE_ONUSE) { + this.flags |= RawStoreFlags.HAS_VISIBLE_ONUSE; + } + return updateLink(link); + } + + override startUse(): void { + const producerLinks = this.producerLinks; + if (producerLinks) { + for (let i = 0, l = producerLinks.length; i < l; i++) { + const link = producerLinks[i]; + link.producer.registerConsumer(link); + } + } + this.flags |= RawStoreFlags.DIRTY; + } + + override endUse(): void { + const producerLinks = this.producerLinks; + if (producerLinks) { + for (let i = 0, l = producerLinks.length; i < l; i++) { + const link = producerLinks[i]; + link.producer.unregisterConsumer(link); + } + } + } + + override areProducersUpToDate(): boolean { + const producerLinks = this.producerLinks; + if (producerLinks) { + for (let i = 0, l = producerLinks.length; i < l; i++) { + const link = producerLinks[i]; + updateLinkProducerValue(link); + if (!isLinkUpToDate(link)) { + return false; + } + } + } else if (this.value === COMPUTED_UNSET) { + return false; + } + return true; + } + + override recompute(): void { + let value: T; + const prevActiveConsumer = setActiveConsumer(this); + try { + this.producerIndex = 0; + this.flags &= ~RawStoreFlags.HAS_VISIBLE_ONUSE; + const computeFn = this.computeFn; + value = computeFn(); + this.error = null; + } catch (error) { + value = COMPUTED_ERRORED; + this.error = error; + } finally { + setActiveConsumer(prevActiveConsumer); + } + // Remove unused producers: + const producerLinks = this.producerLinks; + const producerIndex = this.producerIndex; + if (producerLinks && producerIndex < producerLinks.length) { + for (let i = 0, l = producerLinks.length - producerIndex; i < l; i++) { + const link = producerLinks.pop()!; + link.producer.unregisterConsumer(link); + } + } + this.set(value); + } +} diff --git a/src/internal/storeComputedOrDerived.ts b/src/internal/storeComputedOrDerived.ts new file mode 100644 index 0000000..fe42f0b --- /dev/null +++ b/src/internal/storeComputedOrDerived.ts @@ -0,0 +1,80 @@ +import { RawStoreFlags, type Consumer } from './store'; +import { RawStoreTrackingUsage } from './storeTrackingUsage'; +import { setActiveConsumer } from './untrack'; + +const MAX_CHANGE_RECOMPUTES = 1000; + +export const COMPUTED_UNSET: any = Symbol('UNSET'); +export const COMPUTED_ERRORED: any = Symbol('ERRORED'); +export const isComputedSpecialValue = (value: unknown): boolean => + value === COMPUTED_UNSET || value === COMPUTED_ERRORED; + +export abstract class RawStoreComputedOrDerived + extends RawStoreTrackingUsage + implements Consumer +{ + override flags = RawStoreFlags.DIRTY; + error: any; + + override equal(a: T, b: T): boolean { + if (isComputedSpecialValue(a) || isComputedSpecialValue(b)) { + return false; + } + return super.equal(a, b); + } + + markDirty(): void { + if (!(this.flags & RawStoreFlags.DIRTY)) { + this.flags |= RawStoreFlags.DIRTY; + this.markConsumersDirty(); + } + } + + override readValue(): T { + const value = this.value; + if (value === COMPUTED_ERRORED) { + throw this.error; + } + /* v8 ignore next 3 */ + if (value === COMPUTED_UNSET) { + throw new Error('assert failed: computed value is not set'); + } + return value; + } + + override updateValue(): void { + if (this.flags & RawStoreFlags.COMPUTING) { + throw new Error('recursive computed'); + } + super.updateValue(); + if (!(this.flags & RawStoreFlags.DIRTY)) { + return; + } + this.flags |= RawStoreFlags.COMPUTING; + const prevActiveConsumer = setActiveConsumer(null); + try { + let iterations = 0; + do { + do { + iterations++; + this.flags &= ~RawStoreFlags.DIRTY; + if (this.areProducersUpToDate()) { + return; + } + } while (this.flags & RawStoreFlags.DIRTY && iterations < MAX_CHANGE_RECOMPUTES); + this.recompute(); + } while (this.flags & RawStoreFlags.DIRTY && iterations < MAX_CHANGE_RECOMPUTES); + if (this.flags & RawStoreFlags.DIRTY) { + this.flags &= ~RawStoreFlags.DIRTY; + this.error = new Error('reached maximum number of store changes in one shot'); + this.set(COMPUTED_ERRORED); + } + } finally { + setActiveConsumer(prevActiveConsumer); + this.flags &= ~RawStoreFlags.COMPUTING; + } + } + + abstract areProducersUpToDate(): boolean; + abstract recompute(): void; +} diff --git a/src/internal/storeConst.ts b/src/internal/storeConst.ts new file mode 100644 index 0000000..158a0ab --- /dev/null +++ b/src/internal/storeConst.ts @@ -0,0 +1,32 @@ +import type { Subscriber, UnsubscribeFunction, UnsubscribeObject } from '../types'; +import type { Consumer, ProducerConsumerLink } from './store'; +import { checkNotInNotificationPhase, RawStore, toSubscriberObject } from './store'; +import { noopUnsubscribe } from './unsubscribe'; + +export class RawStoreConst extends RawStore { + link: ProducerConsumerLink = { + producer: this, + consumer: null as any, + indexInProducer: -1, + skipMarkDirty: false, + value: this.value, + version: this.version, + }; + + override get(): T { + checkNotInNotificationPhase(); + return this.value; + } + override subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject { + checkNotInNotificationPhase(); + toSubscriberObject(subscriber).next(this.value); + return noopUnsubscribe; + } + override newLink(_consumer: Consumer | null): ProducerConsumerLink { + return this.link; + } + override registerConsumer(link: ProducerConsumerLink): ProducerConsumerLink { + return link; + } + override unregisterConsumer(_link: ProducerConsumerLink): void {} +} diff --git a/src/internal/storeDerived.ts b/src/internal/storeDerived.ts new file mode 100644 index 0000000..e08bf46 --- /dev/null +++ b/src/internal/storeDerived.ts @@ -0,0 +1,143 @@ +import type { + AsyncDeriveFn, + OnUseArgument, + StoreInput, + StoresInput, + StoresInputValues, + SyncDeriveFn, + UnsubscribeFunction, + Unsubscriber, +} from '../types'; +import { getRawStore } from './exposeRawStores'; +import type { Consumer, ProducerConsumerLink, RawStore } from './store'; +import { isLinkUpToDate, RawStoreFlags, updateLink, updateLinkProducerValue } from './store'; +import { + COMPUTED_ERRORED, + COMPUTED_UNSET, + RawStoreComputedOrDerived, +} from './storeComputedOrDerived'; +import { normalizeUnsubscribe } from './unsubscribe'; + +abstract class RawStoreDerived + extends RawStoreComputedOrDerived + implements Consumer +{ + arrayMode: boolean; + producers: RawStore[]; + producerLinks: ProducerConsumerLink[] | null = null; + cleanUpFn: UnsubscribeFunction | null = null; + override flags = RawStoreFlags.HAS_VISIBLE_ONUSE | RawStoreFlags.DIRTY; + + constructor(producers: S, initialValue: T) { + super(initialValue); + const arrayMode = Array.isArray(producers); + this.arrayMode = arrayMode; + this.producers = ( + arrayMode ? (producers as StoreInput[]) : [producers as StoreInput] + ).map(getRawStore); + } + + callCleanUpFn(): void { + const cleanUpFn = this.cleanUpFn; + if (cleanUpFn) { + this.cleanUpFn = null; + cleanUpFn(); + } + } + + override startUse(): void { + this.producerLinks = this.producers.map((producer) => + producer.registerConsumer(producer.newLink(this)) + ); + this.flags |= RawStoreFlags.DIRTY; + } + + override endUse(): void { + this.callCleanUpFn(); + const producerLinks = this.producerLinks; + this.producerLinks = null; + if (producerLinks) { + for (let i = 0, l = producerLinks.length; i < l; i++) { + const link = producerLinks[i]; + link.producer.unregisterConsumer(link); + } + } + } + + override areProducersUpToDate(): boolean { + const producerLinks = this.producerLinks!; + let alreadyUpToDate = this.value !== COMPUTED_UNSET; + for (let i = 0, l = producerLinks.length; i < l; i++) { + const link = producerLinks[i]; + updateLinkProducerValue(link); + if (!isLinkUpToDate(link)) { + alreadyUpToDate = false; + } + } + return alreadyUpToDate; + } + + override recompute(): void { + try { + this.callCleanUpFn(); + const values = this.producerLinks!.map((link) => updateLink(link)); + this.cleanUpFn = normalizeUnsubscribe(this.derive(this.arrayMode ? values : values[0])); + } catch (error) { + this.error = error; + this.set(COMPUTED_ERRORED); + } + } + + protected abstract derive(values: S): void; +} + +export class RawStoreDerivedStore extends RawStoreDerived { + constructor( + stores: S, + initialValue: T, + public deriveFn: (values: StoresInputValues) => void + ) { + super(stores, initialValue); + } + + protected override derive(values: StoresInputValues): void { + const deriveFn = this.deriveFn; + return deriveFn(values); + } +} + +export class RawStoreSyncDerived extends RawStoreDerived { + constructor( + stores: S, + initialValue: T, + public deriveFn: SyncDeriveFn + ) { + super(stores, COMPUTED_UNSET); + } + protected override derive(values: StoresInputValues): void { + const deriveFn = this.deriveFn; + this.set(deriveFn(values)); + } +} + +export const createOnUseArg = (store: RawStore): OnUseArgument => { + const setFn = store.set.bind(store) as any; + setFn.set = setFn; + setFn.update = store.update.bind(store); + return setFn; +}; + +export class RawStoreAsyncDerived extends RawStoreDerived { + setFn = createOnUseArg(this); + constructor( + stores: S, + initialValue: T, + public deriveFn: AsyncDeriveFn + ) { + super(stores, initialValue); + } + protected override derive(values: StoresInputValues): Unsubscriber | void { + const deriveFn = this.deriveFn; + return deriveFn(values, this.setFn); + } +} diff --git a/src/internal/storeSubscribable.ts b/src/internal/storeSubscribable.ts new file mode 100644 index 0000000..a6178b2 --- /dev/null +++ b/src/internal/storeSubscribable.ts @@ -0,0 +1,40 @@ +import type { + SubscribableStore, + SubscriberFunction, + SubscriberObject, + UnsubscribeFunction, +} from '../types'; +import { RawStoreFlags } from './store'; +import { RawStoreTrackingUsage } from './storeTrackingUsage'; +import { normalizeUnsubscribe } from './unsubscribe'; + +export class RawSubscribableWrapper extends RawStoreTrackingUsage { + subscriber: Pick, 'next'> & SubscriberFunction = this.createSubscriber(); + unsubscribe: UnsubscribeFunction | null = null; + override flags = RawStoreFlags.HAS_VISIBLE_ONUSE; + + constructor(public subscribable: SubscribableStore) { + super(undefined as any); + } + + private createSubscriber() { + const subscriber = (value: T) => this.set(value); + subscriber.next = subscriber; + subscriber.pause = () => { + this.markConsumersDirty(); + }; + return subscriber; + } + + override startUse(): void { + this.unsubscribe = normalizeUnsubscribe(this.subscribable.subscribe(this.subscriber)); + } + + override endUse(): void { + const unsubscribe = this.unsubscribe; + if (unsubscribe) { + this.unsubscribe = null; + unsubscribe(); + } + } +} diff --git a/src/internal/storeTrackingUsage.ts b/src/internal/storeTrackingUsage.ts new file mode 100644 index 0000000..d78f4e3 --- /dev/null +++ b/src/internal/storeTrackingUsage.ts @@ -0,0 +1,83 @@ +import { checkNotInNotificationPhase, RawStore, RawStoreFlags } from './store'; +import { activeConsumer, untrack } from './untrack'; + +let flushUnusedQueue: RawStoreTrackingUsage[] | null = null; +let inFlushUnused = false; + +export const flushUnused = (): void => { + /* v8 ignore next 3 */ + if (inFlushUnused) { + throw new Error('assert failed: recursive flushUnused call'); + } + inFlushUnused = true; + try { + const queue = flushUnusedQueue; + if (queue) { + flushUnusedQueue = null; + for (let i = 0, l = queue.length; i < l; i++) { + const producer = queue[i]; + producer.flags &= ~RawStoreFlags.FLUSH_PLANNED; + producer.checkUnused(); + } + } + } finally { + inFlushUnused = false; + } +}; + +export abstract class RawStoreTrackingUsage extends RawStore { + abstract startUse(): void; + abstract endUse(): void; + + override updateValue(): void { + const flags = this.flags; + if (!(flags & RawStoreFlags.START_USE_CALLED)) { + /* v8 ignore next 3 */ + if (!(flags & RawStoreFlags.INSIDE_GET) && !this.consumerLinks?.length) { + throw new Error('assert failed: untracked producer usage'); + } + this.flags |= RawStoreFlags.START_USE_CALLED; + untrack(() => this.startUse()); + } + } + + override checkUnused(): void { + const flags = this.flags; + /* v8 ignore next 3 */ + if (flags & RawStoreFlags.INSIDE_GET) { + throw new Error('assert failed: INSIDE_GET flag in checkUnused'); + } + if (flags & RawStoreFlags.START_USE_CALLED && !this.consumerLinks?.length) { + if (inFlushUnused || flags & RawStoreFlags.HAS_VISIBLE_ONUSE) { + this.flags &= ~RawStoreFlags.START_USE_CALLED; + untrack(() => this.endUse()); + } else if (!(flags & RawStoreFlags.FLUSH_PLANNED)) { + this.flags |= RawStoreFlags.FLUSH_PLANNED; + if (!flushUnusedQueue) { + flushUnusedQueue = []; + queueMicrotask(flushUnused); + } + flushUnusedQueue.push(this); + } + } + } + + override get(): T { + checkNotInNotificationPhase(); + if (activeConsumer) { + return activeConsumer.addProducer(this); + } else { + if (this.flags & RawStoreFlags.INSIDE_GET) { + throw new Error('recursive computed'); + } + this.flags |= RawStoreFlags.INSIDE_GET; + try { + this.updateValue(); + return this.readValue(); + } finally { + this.flags &= ~RawStoreFlags.INSIDE_GET; + this.checkUnused(); + } + } + } +} diff --git a/src/internal/storeWithOnUse.ts b/src/internal/storeWithOnUse.ts new file mode 100644 index 0000000..d3881f2 --- /dev/null +++ b/src/internal/storeWithOnUse.ts @@ -0,0 +1,29 @@ +import type { UnsubscribeFunction, Unsubscriber } from '../types'; +import { RawStoreFlags } from './store'; +import { RawStoreTrackingUsage } from './storeTrackingUsage'; +import { normalizeUnsubscribe } from './unsubscribe'; + +export class RawStoreWithOnUse extends RawStoreTrackingUsage { + cleanUpFn: UnsubscribeFunction | null = null; + override flags = RawStoreFlags.HAS_VISIBLE_ONUSE; + + constructor( + value: T, + public onUseFn: () => Unsubscriber | void + ) { + super(value); + } + + override startUse(): void { + const onUseFn = this.onUseFn; + this.cleanUpFn = normalizeUnsubscribe(onUseFn()); + } + + override endUse(): void { + const cleanUpFn = this.cleanUpFn; + if (cleanUpFn) { + this.cleanUpFn = null; + cleanUpFn(); + } + } +} diff --git a/src/internal/unsubscribe.ts b/src/internal/unsubscribe.ts new file mode 100644 index 0000000..0fd2733 --- /dev/null +++ b/src/internal/unsubscribe.ts @@ -0,0 +1,19 @@ +import type { UnsubscribeFunction, UnsubscribeObject, Unsubscriber } from '../types'; + +export const noopUnsubscribe = (): void => {}; +noopUnsubscribe.unsubscribe = noopUnsubscribe; + +export const normalizeUnsubscribe = ( + unsubscribe: Unsubscriber | void | null | undefined +): UnsubscribeFunction & UnsubscribeObject => { + if (!unsubscribe) { + return noopUnsubscribe; + } + if ((unsubscribe as any).unsubscribe === unsubscribe) { + return unsubscribe as any; + } + const res: any = + typeof unsubscribe === 'function' ? () => unsubscribe() : () => unsubscribe.unsubscribe(); + res.unsubscribe = res; + return res; +}; diff --git a/src/internal/untrack.ts b/src/internal/untrack.ts new file mode 100644 index 0000000..77dd724 --- /dev/null +++ b/src/internal/untrack.ts @@ -0,0 +1,31 @@ +import type { RawStore } from './store'; + +export interface ActiveConsumer { + addProducer: (store: RawStore) => T; +} + +export let activeConsumer: ActiveConsumer | null = null; + +export const setActiveConsumer = (consumer: ActiveConsumer | null): ActiveConsumer | null => { + const prevConsumer = activeConsumer; + activeConsumer = consumer; + return prevConsumer; +}; + +/** + * Stops the tracking of dependencies made by {@link computed} and calls the provided function. + * After the function returns, the tracking of dependencies continues as before. + * + * @param fn - function to be called + * @returns the value returned by the given function + */ +export const untrack = (fn: () => T): T => { + let output: T; + const prevActiveConsumer = setActiveConsumer(null); + try { + output = fn(); + } finally { + setActiveConsumer(prevActiveConsumer); + } + return output; +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f5e5ba8 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,259 @@ +declare global { + interface SymbolConstructor { + readonly observable: symbol; + } +} + +/** + * A callback invoked when a store value changes. It is called with the latest value of a given store. + */ +export type SubscriberFunction = ((value: T) => void) & + Partial, 'next'>>; + +/** + * A partial {@link https://github.com/tc39/proposal-observable#api | observer} notified when a store value changes. A store will call the {@link SubscriberObject.next | next} method every time the store's state is changing. + */ +export interface SubscriberObject { + /** + * A store will call this method every time the store's state is changing. + */ + next: SubscriberFunction; + /** + * Unused, only declared for compatibility with rxjs. + */ + error?: any; + /** + * Unused, only declared for compatibility with rxjs. + */ + complete?: any; + /** + * A store will call this method when it knows that the value will be changed. + * A call to this method will be followed by a call to {@link SubscriberObject.next | next} or to {@link SubscriberObject.resume | resume}. + */ + pause: () => void; + /** + * A store will call this method if {@link SubscriberObject.pause | pause} was called previously + * and the value finally did not need to change. + */ + resume: () => void; +} + +/** + * Expresses interest in store value changes over time. It can be either: + * - a callback function: {@link SubscriberFunction}; + * - a partial observer: {@link SubscriberObject}. + */ +export type Subscriber = SubscriberFunction | Partial> | null | undefined; + +/** + * A function to unsubscribe from value change notifications. + */ +export type UnsubscribeFunction = () => void; + +/** + * An object with the `unsubscribe` method. + * Subscribable stores might choose to return such object instead of directly returning {@link UnsubscribeFunction} from a subscription call. + */ +export interface UnsubscribeObject { + /** + * A method that acts as the {@link UnsubscribeFunction}. + */ + unsubscribe: UnsubscribeFunction; +} + +export type Unsubscriber = UnsubscribeObject | UnsubscribeFunction; + +/** + * Represents a store accepting registrations (subscribers) and "pushing" notifications on each and every store value change. + */ +export interface SubscribableStore { + /** + * A method that makes it possible to register "interest" in store value changes over time. + * It is called each and every time the store's value changes. + * + * A registered subscriber is notified synchronously with the latest store value. + * + * @param subscriber - a subscriber in a form of a {@link SubscriberFunction} or a {@link SubscriberObject}. Returns a {@link Unsubscriber} (function or object with the `unsubscribe` method) that can be used to unregister and stop receiving notifications of store value changes. + * @returns The {@link UnsubscribeFunction} or {@link UnsubscribeObject} that can be used to unsubscribe (stop state change notifications). + */ + subscribe(subscriber: Subscriber): Unsubscriber; +} + +/** + * An interface for interoperability between observable implementations. It only has to expose the `[Symbol.observable]` method that is supposed to return a subscribable store. + */ +export interface InteropObservable { + [Symbol.observable]: () => SubscribableStore; +} + +/** + * Valid types that can be considered as a store. + */ +export type StoreInput = SubscribableStore | InteropObservable; + +export interface SignalStore { + /** + * Returns the value of the store. + */ + get(): T; +} + +/** + * This interface augments the base {@link SubscribableStore} interface by requiring the return value of the subscribe method to be both a function and an object with the `unsubscribe` method. + * + * For {@link https://rxjs.dev/api/index/interface/InteropObservable | interoperability with rxjs}, it also implements the `[Symbol.observable]` method. + */ +export interface Readable extends SubscribableStore, InteropObservable, SignalStore { + subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject; + [Symbol.observable](): Readable; +} + +/** + * This interface augments the base {@link Readable} interface by adding the ability to call the store as a function to get its value. + */ +export interface ReadableSignal extends Readable { + /** + * Returns the value of the store. + */ + (): T; +} + +/** + * A function that can be used to update store's value. This function is called with the current value and should return new store value. + */ +export type Updater = (value: T) => U; + +/** + * Builds on top of {@link Readable} and represents a store that can be manipulated from "outside": anyone with a reference to writable store can either update or completely replace state of a given store. + * + * @example + * + * ```typescript + * // reset counter's store value to 0 by using the {@link Writable.set} method + * counterStore.set(0); + * + * // increment counter's store value by using the {@link Writable.update} method + * counterStore.update(currentValue => currentValue + 1); + * ``` + */ +export interface Writable extends Readable { + /** + * Replaces store's state with the provided value. + * @param value - value to be used as the new state of a store. + */ + set(value: U): void; + + /** + * Updates store's state by using an {@link Updater} function. + * @param updater - a function that takes the current state as an argument and returns the new state. + */ + update(updater: Updater): void; +} + +/** + * Represents a store that implements both {@link ReadableSignal} and {@link Writable}. + * This is the type of objects returned by {@link writable}. + */ +export interface WritableSignal extends ReadableSignal, Writable {} + +export interface OnUseArgument { + (value: T): void; + set: (value: T) => void; + update: (updater: Updater) => void; +} + +/** + * Type of a function that is called when the number of subscribers changes from 0 to 1 + * (but not called when the number of subscribers changes from 1 to 2, ...). + * + * If it returns a function, that function will be called when the number of subscribers changes from 1 to 0. + */ +export type OnUseFn = (arg: OnUseArgument) => void | Unsubscriber; + +/** + * Store options that can be passed to {@link readable} or {@link writable}. + */ +export interface StoreOptions { + /** + * A function that is called when the number of subscribers changes from 0 to 1 + * (but not called when the number of subscribers changes from 1 to 2, ...). + * If it returns a function, that function will be called when the number of subscribers changes from 1 to 0. + */ + onUse?: OnUseFn; + + /** + * Custom function to compare two values, that should return true if they + * are equal. + * + * It is called when setting a new value to avoid doing anything + * (such as notifying subscribers) if the value did not change. + * + * @remarks + * The default logic (when this option is not present) is to return false + * if `a` is a function or an object, or if `a` and `b` are different + * according to `Object.is`. + * + * {@link StoreOptions.equal|equal} takes precedence over {@link StoreOptions.notEqual|notEqual} if both + * are defined. + * + * @param a - First value to compare. + * @param b - Second value to compare. + * @returns true if a and b are considered equal. + */ + equal?: (a: T, b: T) => boolean; + + /** + * Custom function to compare two values, that should return true if they + * are different. + * + * It is called when setting a new value to avoid doing anything + * (such as notifying subscribers) if the value did not change. + * + * @remarks + * The default logic (when this option is not present) is to return true + * if `a` is a function or an object, or if `a` and `b` are different + * according to `Object.is`. + * + * {@link StoreOptions.equal} takes precedence over {@link StoreOptions.notEqual|notEqual} if both + * are defined. + * + * @deprecated Use {@link StoreOptions.equal} instead + * @param a - First value to compare. + * @param b - Second value to compare. + * @returns true if a and b are considered different. + */ + notEqual?: (a: T, b: T) => boolean; +} + +/** + * Either a single {@link StoreInput} or a read-only array of at least one {@link StoreInput}. + */ +export type StoresInput = StoreInput | readonly [StoreInput, ...StoreInput[]]; + +/** + * Extracts the types of the values of the stores from a type extending {@link StoresInput}. + * + * @remarks + * + * If the type given as a parameter is a single {@link StoreInput}, the type of the value + * of that {@link StoreInput} is returned. + * + * If the type given as a parameter is one of an array of {@link StoreInput}, the returned type + * is the type of an array containing the value of each store in the same order. + */ +export type StoresInputValues = + S extends StoreInput + ? T + : { [K in keyof S]: S[K] extends StoreInput ? T : never }; + +export type SyncDeriveFn = (values: StoresInputValues) => T; +export interface SyncDeriveOptions extends Omit, 'onUse'> { + derive: SyncDeriveFn; +} +export type AsyncDeriveFn = ( + values: StoresInputValues, + set: OnUseArgument +) => Unsubscriber | void; +export interface AsyncDeriveOptions extends Omit, 'onUse'> { + derive: AsyncDeriveFn; +} From 4c45668c02b6bbaf8aec929863f6361c13be5edc Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Fri, 8 Nov 2024 13:34:28 +0100 Subject: [PATCH 02/18] Transforming RawStore into an interface --- src/index.spec.ts | 5 +- src/index.ts | 23 ++- src/internal/batch.ts | 4 +- src/internal/store.ts | 260 +++---------------------- src/internal/storeComputed.ts | 23 +-- src/internal/storeComputedOrDerived.ts | 3 +- src/internal/storeConst.ts | 47 +++-- src/internal/storeDerived.ts | 14 +- src/internal/storeTrackingUsage.ts | 5 +- src/internal/storeWritable.ts | 168 ++++++++++++++++ src/internal/subscribeConsumer.ts | 64 ++++++ src/internal/untrack.ts | 4 +- 12 files changed, 322 insertions(+), 298 deletions(-) create mode 100644 src/internal/storeWritable.ts create mode 100644 src/internal/subscribeConsumer.ts diff --git a/src/index.spec.ts b/src/index.spec.ts index da7a545..1f8a922 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -30,11 +30,12 @@ import { writable, } from './index'; import { rawStoreSymbol } from './internal/exposeRawStores'; -import { RawStoreFlags, type RawStore } from './internal/store'; +import { RawStoreFlags } from './internal/store'; import { flushUnused } from './internal/storeTrackingUsage'; +import type { RawStoreWritable } from './internal/storeWritable'; const expectCorrectlyCleanedUp = (store: StoreInput) => { - const rawStore = (store as any)[rawStoreSymbol] as RawStore; + const rawStore = (store as any)[rawStoreSymbol] as RawStoreWritable; expect(rawStore.consumerLinks?.length ?? 0).toBe(0); expect(rawStore.flags & RawStoreFlags.START_USE_CALLED).toBeFalsy(); }; diff --git a/src/index.ts b/src/index.ts index 6d93d28..6f2066e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,6 @@ import { rawStoreSymbol, symbolObservable, } from './internal/exposeRawStores'; -import { noop, RawStore } from './internal/store'; import { RawStoreComputed } from './internal/storeComputed'; import { RawStoreConst } from './internal/storeConst'; import { @@ -22,6 +21,8 @@ import { RawStoreSyncDerived, } from './internal/storeDerived'; import { RawStoreWithOnUse } from './internal/storeWithOnUse'; +import { RawStoreWritable } from './internal/storeWritable'; +import { noop } from './internal/subscribeConsumer'; import { untrack } from './internal/untrack'; import type { AsyncDeriveFn, @@ -183,18 +184,20 @@ export abstract class Store implements Readable { */ constructor(value: T) { let rawStore; - if (value instanceof RawStore) { + if (value instanceof RawStoreWritable) { rawStore = value; } else { const onUse = this.onUse; - rawStore = onUse ? new RawStoreWithOnUse(value, onUse.bind(this)) : new RawStore(value); + rawStore = onUse + ? new RawStoreWithOnUse(value, onUse.bind(this)) + : new RawStoreWritable(value); rawStore.equalFn = (a, b) => this.equal(a, b); } this[rawStoreSymbol] = rawStore; } /** @internal */ - [rawStoreSymbol]: RawStore; + [rawStoreSymbol]: RawStoreWritable; /** * Compares two values and returns true if they are equal. @@ -246,11 +249,11 @@ export abstract class Store implements Readable { * @param value - value to be used as the new state of a store. */ protected set(value: T): void { - getRawStore(this).set(value); + this[rawStoreSymbol].set(value); } get(): T { - return getRawStore(this).get(); + return this[rawStoreSymbol].get(); } /** @@ -260,7 +263,7 @@ export abstract class Store implements Readable { * @param updater - a function that takes the current state as an argument and returns the new state. */ protected update(updater: Updater): void { - getRawStore(this).update(updater); + this[rawStoreSymbol].update(updater); } /** @@ -294,7 +297,7 @@ export abstract class Store implements Readable { * @param subscriber - see {@link SubscribableStore.subscribe} */ subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject { - return getRawStore(this).subscribe(subscriber); + return this[rawStoreSymbol].subscribe(subscriber); } [symbolObservable](): this { @@ -308,7 +311,7 @@ const createStoreWithOnUse = (initValue: T, onUse: OnUseFn) => { return store; }; -const applyStoreOptions = >( +const applyStoreOptions = >( store: S, options?: Omit, 'onUse'> ): S => { @@ -381,7 +384,7 @@ export function writable(value: T, options?: StoreOptions | OnUseFn): W } const onUse = options?.onUse; const store = applyStoreOptions( - onUse ? createStoreWithOnUse(value, onUse) : new RawStore(value), + onUse ? createStoreWithOnUse(value, onUse) : new RawStoreWritable(value), options ); const res = exposeRawStore(store) as any; diff --git a/src/internal/batch.ts b/src/internal/batch.ts index cee04ba..8469a39 100644 --- a/src/internal/batch.ts +++ b/src/internal/batch.ts @@ -1,6 +1,6 @@ -import type { SubscribeConsumer } from './store'; +import type { SubscribeConsumer } from './subscribeConsumer'; -export const subscribersQueue: SubscribeConsumer[] = []; +export const subscribersQueue: SubscribeConsumer[] = []; let willProcessQueue = false; /** diff --git a/src/internal/store.ts b/src/internal/store.ts index f3d5c33..5bb0832 100644 --- a/src/internal/store.ts +++ b/src/internal/store.ts @@ -1,74 +1,9 @@ -import type { - SignalStore, - SubscribableStore, - Subscriber, - SubscriberObject, - UnsubscribeFunction, - UnsubscribeObject, - Updater, -} from '../types'; -import { batch, subscribersQueue } from './batch'; -import { equal } from './equal'; -import { activeConsumer } from './untrack'; - -export let notificationPhase = false; - -export const checkNotInNotificationPhase = (): void => { - if (notificationPhase) { - throw new Error('Reading or writing a signal is forbidden during the notification phase.'); - } -}; - -export let epoch = 0; +import type { SignalStore, SubscribableStore } from '../types'; export interface Consumer { markDirty(): void; } -export interface ProducerConsumerLink { - value: T; - version: number; - producer: RawStore; - indexInProducer: number; - consumer: Consumer | null; - skipMarkDirty: boolean; -} - -export const updateLinkProducerValue = (link: ProducerConsumerLink): void => { - try { - link.skipMarkDirty = true; - link.producer.updateValue(); - } finally { - link.skipMarkDirty = false; - } -}; - -export const isLinkUpToDate = (link: ProducerConsumerLink): boolean => { - const producer = link.producer; - if (link.version === producer.version) { - return true; - } - if (link.version === producer.version - 1 || link.version < 0) { - return false; - } - if (!producer.equalCache) { - producer.equalCache = {}; - } - let res = producer.equalCache[link.version]; - if (res === undefined) { - res = producer.equal(link.value, producer.value); - producer.equalCache[link.version] = res; - } - return res; -}; - -export const updateLink = (link: ProducerConsumerLink): T => { - const producer = link.producer; - link.value = producer.value; - link.version = producer.version; - return producer.readValue(); -}; - export const enum RawStoreFlags { NONE = 0, // the following flags are used in RawStoreTrackingUsage and derived classes @@ -81,179 +16,28 @@ export const enum RawStoreFlags { DIRTY = 1 << 5, } -export class RawStore implements SignalStore, SubscribableStore { - constructor(public value: T) {} - flags = RawStoreFlags.NONE; - version = 0; - equalFn = equal; - equalCache: Record | null = null; - consumerLinks: null | ProducerConsumerLink[] = null; - - newLink(consumer: Consumer | null): ProducerConsumerLink { - return { - version: -1, - value: undefined as any, - producer: this, - indexInProducer: 0, - consumer, - skipMarkDirty: false, - }; - } - - registerConsumer(link: ProducerConsumerLink): ProducerConsumerLink { - let consumerLinks = this.consumerLinks; - if (!consumerLinks) { - consumerLinks = []; - this.consumerLinks = consumerLinks; - } - const indexInProducer = consumerLinks.length; - link.indexInProducer = indexInProducer; - consumerLinks[indexInProducer] = link; - return link; - } - - unregisterConsumer(link: ProducerConsumerLink): void { - const consumerLinks = this.consumerLinks; - const index = link.indexInProducer; - /* v8 ignore next 3 */ - if (consumerLinks?.[index] !== link) { - throw new Error('assert failed: invalid indexInProducer'); - } - // swap with the last item to avoid shifting the array - const lastConsumerLink = consumerLinks.pop()!; - const isLast = link === lastConsumerLink; - if (!isLast) { - consumerLinks[index] = lastConsumerLink; - lastConsumerLink.indexInProducer = index; - } else if (index === 0) { - this.checkUnused(); - } - } - - checkUnused(): void {} - updateValue(): void {} - - equal(a: T, b: T): boolean { - const equalFn = this.equalFn; - return equalFn(a, b); - } - - protected increaseEpoch(): void { - epoch++; - this.markConsumersDirty(); - } - - set(newValue: T): void { - checkNotInNotificationPhase(); - const same = this.equal(this.value, newValue); - if (!same) { - batch(() => { - this.value = newValue; - this.version++; - this.equalCache = null; - this.increaseEpoch(); - }); - } - } - - update(updater: Updater): void { - this.set(updater(this.value)); - } - - markConsumersDirty(): void { - const prevNotificationPhase = notificationPhase; - notificationPhase = true; - try { - const consumerLinks = this.consumerLinks; - if (consumerLinks) { - for (let i = 0, l = consumerLinks.length; i < l; i++) { - const link = consumerLinks[i]; - if (link.skipMarkDirty) continue; - link.consumer?.markDirty?.(); - } - } - } finally { - notificationPhase = prevNotificationPhase; - } - } - - get(): T { - checkNotInNotificationPhase(); - if (activeConsumer) { - return activeConsumer.addProducer(this); - } else { - return this.readValue(); - } - } - - readValue(): T { - return this.value; - } - - subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject { - checkNotInNotificationPhase(); - const subscription = new SubscribeConsumer(this, subscriber); - const unsubscriber = () => subscription.unsubscribe(); - unsubscriber.unsubscribe = unsubscriber; - return unsubscriber; - } +export interface BaseLink { + producer: RawStore>; + skipMarkDirty?: boolean; } -export const noop = (): void => {}; - -const bind = (object: T | null | undefined, fnName: keyof T) => { - const fn = object ? object[fnName] : null; - return typeof fn === 'function' ? fn.bind(object) : noop; -}; - -const noopSubscriber: SubscriberObject = { - next: noop, - pause: noop, - resume: noop, -}; - -export const toSubscriberObject = (subscriber: Subscriber): SubscriberObject => ({ - next: typeof subscriber === 'function' ? subscriber.bind(null) : bind(subscriber, 'next'), - pause: bind(subscriber, 'pause'), - resume: bind(subscriber, 'resume'), -}); - -export class SubscribeConsumer implements Consumer { - link: ProducerConsumerLink; - subscriber: SubscriberObject; - dirtyCount = 1; - constructor(producer: RawStore, subscriber: Subscriber) { - this.subscriber = toSubscriberObject(subscriber); - this.link = producer.registerConsumer(producer.newLink(this)); - this.notify(); - } - - unsubscribe(): void { - if (this.subscriber !== noopSubscriber) { - this.subscriber = noopSubscriber; - this.link.producer.unregisterConsumer(this.link); - } - } - - markDirty(): void { - this.dirtyCount++; - subscribersQueue.push(this); - if (this.dirtyCount === 1) { - this.subscriber.pause(); - } - } +export interface RawStore = BaseLink> + extends SignalStore, + SubscribableStore { + flags: RawStoreFlags; + newLink(consumer: Consumer): Link; + registerConsumer(link: Link): Link; + unregisterConsumer(link: Link): void; + updateValue(): void; + isLinkUpToDate(link: Link): boolean; + updateLink(link: Link): T; +} - notify(): void { - this.dirtyCount--; - if (this.dirtyCount === 0 && this.subscriber !== noopSubscriber) { - updateLinkProducerValue(this.link); - if (isLinkUpToDate(this.link)) { - this.subscriber.resume(); - } else { - // note that the following line can throw - const value = updateLink(this.link); - this.subscriber.next(value); - } - } +export const updateLinkProducerValue = (link: BaseLink): void => { + try { + link.skipMarkDirty = true; + link.producer.updateValue(); + } finally { + link.skipMarkDirty = false; } -} +}; diff --git a/src/internal/storeComputed.ts b/src/internal/storeComputed.ts index 55d8b62..3225a1d 100644 --- a/src/internal/storeComputed.ts +++ b/src/internal/storeComputed.ts @@ -1,17 +1,11 @@ -import type { Consumer, ProducerConsumerLink, RawStore } from './store'; -import { - epoch, - isLinkUpToDate, - notificationPhase, - RawStoreFlags, - updateLink, - updateLinkProducerValue, -} from './store'; +import type { BaseLink, Consumer, RawStore } from './store'; +import { RawStoreFlags, updateLinkProducerValue } from './store'; import { COMPUTED_ERRORED, COMPUTED_UNSET, RawStoreComputedOrDerived, } from './storeComputedOrDerived'; +import { epoch, notificationPhase } from './storeWritable'; import { activeConsumer, setActiveConsumer, type ActiveConsumer } from './untrack'; export class RawStoreComputed @@ -19,7 +13,7 @@ export class RawStoreComputed implements Consumer, ActiveConsumer { producerIndex = 0; - producerLinks: ProducerConsumerLink[] | null = null; + producerLinks: BaseLink[] | null = null; epoch = -1; constructor(public computeFn: () => T) { @@ -52,14 +46,14 @@ export class RawStoreComputed return super.get(); } - addProducer(producer: RawStore): U { + addProducer>(producer: RawStore): U { let producerLinks = this.producerLinks; if (!producerLinks) { producerLinks = []; this.producerLinks = producerLinks; } const producerIndex = this.producerIndex; - let link = producerLinks[producerIndex]; + let link = producerLinks[producerIndex] as L | undefined; if (link?.producer !== producer) { if (link) { const endIndex = Math.max(producerIndex + 1, producerLinks.length); @@ -73,7 +67,7 @@ export class RawStoreComputed if (producer.flags & RawStoreFlags.HAS_VISIBLE_ONUSE) { this.flags |= RawStoreFlags.HAS_VISIBLE_ONUSE; } - return updateLink(link); + return producer.updateLink(link); } override startUse(): void { @@ -102,8 +96,9 @@ export class RawStoreComputed if (producerLinks) { for (let i = 0, l = producerLinks.length; i < l; i++) { const link = producerLinks[i]; + const producer = link.producer; updateLinkProducerValue(link); - if (!isLinkUpToDate(link)) { + if (!producer.isLinkUpToDate(link)) { return false; } } diff --git a/src/internal/storeComputedOrDerived.ts b/src/internal/storeComputedOrDerived.ts index fe42f0b..06393d5 100644 --- a/src/internal/storeComputedOrDerived.ts +++ b/src/internal/storeComputedOrDerived.ts @@ -1,4 +1,5 @@ -import { RawStoreFlags, type Consumer } from './store'; +import type { Consumer } from './store'; +import { RawStoreFlags } from './store'; import { RawStoreTrackingUsage } from './storeTrackingUsage'; import { setActiveConsumer } from './untrack'; diff --git a/src/internal/storeConst.ts b/src/internal/storeConst.ts index 158a0ab..e1a4a15 100644 --- a/src/internal/storeConst.ts +++ b/src/internal/storeConst.ts @@ -1,32 +1,37 @@ -import type { Subscriber, UnsubscribeFunction, UnsubscribeObject } from '../types'; -import type { Consumer, ProducerConsumerLink } from './store'; -import { checkNotInNotificationPhase, RawStore, toSubscriberObject } from './store'; +import type { Subscriber, Unsubscriber } from '../types'; +import type { BaseLink, Consumer, RawStore } from './store'; +import { RawStoreFlags } from './store'; +import { checkNotInNotificationPhase } from './storeWritable'; +import { toSubscriberObject } from './subscribeConsumer'; import { noopUnsubscribe } from './unsubscribe'; -export class RawStoreConst extends RawStore { - link: ProducerConsumerLink = { - producer: this, - consumer: null as any, - indexInProducer: -1, - skipMarkDirty: false, - value: this.value, - version: this.version, - }; +export class RawStoreConst implements RawStore> { + flags = RawStoreFlags.NONE; + constructor(public readonly value: T) {} - override get(): T { + newLink(_consumer: Consumer): BaseLink { + return { + producer: this, + }; + } + registerConsumer(link: BaseLink): BaseLink { + return link; + } + unregisterConsumer(_link: BaseLink): void {} + updateValue(_link?: BaseLink | undefined): void {} + isLinkUpToDate(_link: BaseLink): boolean { + return true; + } + updateLink(_link: BaseLink): T { + return this.value; + } + get(): T { checkNotInNotificationPhase(); return this.value; } - override subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject { + subscribe(subscriber: Subscriber): Unsubscriber { checkNotInNotificationPhase(); toSubscriberObject(subscriber).next(this.value); return noopUnsubscribe; } - override newLink(_consumer: Consumer | null): ProducerConsumerLink { - return this.link; - } - override registerConsumer(link: ProducerConsumerLink): ProducerConsumerLink { - return link; - } - override unregisterConsumer(_link: ProducerConsumerLink): void {} } diff --git a/src/internal/storeDerived.ts b/src/internal/storeDerived.ts index e08bf46..c1851d6 100644 --- a/src/internal/storeDerived.ts +++ b/src/internal/storeDerived.ts @@ -9,13 +9,14 @@ import type { Unsubscriber, } from '../types'; import { getRawStore } from './exposeRawStores'; -import type { Consumer, ProducerConsumerLink, RawStore } from './store'; -import { isLinkUpToDate, RawStoreFlags, updateLink, updateLinkProducerValue } from './store'; +import type { BaseLink, Consumer, RawStore } from './store'; +import { RawStoreFlags, updateLinkProducerValue } from './store'; import { COMPUTED_ERRORED, COMPUTED_UNSET, RawStoreComputedOrDerived, } from './storeComputedOrDerived'; +import type { RawStoreWritable } from './storeWritable'; import { normalizeUnsubscribe } from './unsubscribe'; abstract class RawStoreDerived @@ -24,7 +25,7 @@ abstract class RawStoreDerived { arrayMode: boolean; producers: RawStore[]; - producerLinks: ProducerConsumerLink[] | null = null; + producerLinks: BaseLink[] | null = null; cleanUpFn: UnsubscribeFunction | null = null; override flags = RawStoreFlags.HAS_VISIBLE_ONUSE | RawStoreFlags.DIRTY; @@ -69,8 +70,9 @@ abstract class RawStoreDerived let alreadyUpToDate = this.value !== COMPUTED_UNSET; for (let i = 0, l = producerLinks.length; i < l; i++) { const link = producerLinks[i]; + const producer = link.producer; updateLinkProducerValue(link); - if (!isLinkUpToDate(link)) { + if (!producer.isLinkUpToDate(link)) { alreadyUpToDate = false; } } @@ -80,7 +82,7 @@ abstract class RawStoreDerived override recompute(): void { try { this.callCleanUpFn(); - const values = this.producerLinks!.map((link) => updateLink(link)); + const values = this.producerLinks!.map((link) => link.producer.updateLink(link)); this.cleanUpFn = normalizeUnsubscribe(this.derive(this.arrayMode ? values : values[0])); } catch (error) { this.error = error; @@ -120,7 +122,7 @@ export class RawStoreSyncDerived extends RawStoreDeriv } } -export const createOnUseArg = (store: RawStore): OnUseArgument => { +export const createOnUseArg = (store: RawStoreWritable): OnUseArgument => { const setFn = store.set.bind(store) as any; setFn.set = setFn; setFn.update = store.update.bind(store); diff --git a/src/internal/storeTrackingUsage.ts b/src/internal/storeTrackingUsage.ts index d78f4e3..b2a571c 100644 --- a/src/internal/storeTrackingUsage.ts +++ b/src/internal/storeTrackingUsage.ts @@ -1,4 +1,5 @@ -import { checkNotInNotificationPhase, RawStore, RawStoreFlags } from './store'; +import { RawStoreFlags } from './store'; +import { checkNotInNotificationPhase, RawStoreWritable } from './storeWritable'; import { activeConsumer, untrack } from './untrack'; let flushUnusedQueue: RawStoreTrackingUsage[] | null = null; @@ -25,7 +26,7 @@ export const flushUnused = (): void => { } }; -export abstract class RawStoreTrackingUsage extends RawStore { +export abstract class RawStoreTrackingUsage extends RawStoreWritable { abstract startUse(): void; abstract endUse(): void; diff --git a/src/internal/storeWritable.ts b/src/internal/storeWritable.ts new file mode 100644 index 0000000..ae2c404 --- /dev/null +++ b/src/internal/storeWritable.ts @@ -0,0 +1,168 @@ +import type { Subscriber, UnsubscribeFunction, UnsubscribeObject, Updater } from '../types'; +import { batch } from './batch'; +import { equal } from './equal'; +import type { Consumer, RawStore } from './store'; +import { RawStoreFlags } from './store'; +import { SubscribeConsumer } from './subscribeConsumer'; +import { activeConsumer } from './untrack'; + +export let notificationPhase = false; + +export const checkNotInNotificationPhase = (): void => { + if (notificationPhase) { + throw new Error('Reading or writing a signal is forbidden during the notification phase.'); + } +}; + +export let epoch = 0; + +export interface ProducerConsumerLink { + value: T; + version: number; + producer: RawStore>; + indexInProducer: number; + consumer: Consumer; + skipMarkDirty: boolean; +} + +export class RawStoreWritable implements RawStore> { + constructor(public value: T) {} + flags = RawStoreFlags.NONE; + version = 0; + equalFn = equal; + equalCache: Record | null = null; + consumerLinks: null | ProducerConsumerLink[] = null; + + newLink(consumer: Consumer): ProducerConsumerLink { + return { + version: -1, + value: undefined as any, + producer: this, + indexInProducer: 0, + consumer, + skipMarkDirty: false, + }; + } + + isLinkUpToDate(link: ProducerConsumerLink): boolean { + if (link.version === this.version) { + return true; + } + if (link.version === this.version - 1 || link.version < 0) { + return false; + } + if (!this.equalCache) { + this.equalCache = {}; + } + let res = this.equalCache[link.version]; + if (res === undefined) { + res = this.equal(link.value, this.value); + this.equalCache[link.version] = res; + } + return res; + } + + updateLink(link: ProducerConsumerLink): T { + link.value = this.value; + link.version = this.version; + return this.readValue(); + } + + registerConsumer(link: ProducerConsumerLink): ProducerConsumerLink { + let consumerLinks = this.consumerLinks; + if (!consumerLinks) { + consumerLinks = []; + this.consumerLinks = consumerLinks; + } + const indexInProducer = consumerLinks.length; + link.indexInProducer = indexInProducer; + consumerLinks[indexInProducer] = link; + return link; + } + + unregisterConsumer(link: ProducerConsumerLink): void { + const consumerLinks = this.consumerLinks; + const index = link.indexInProducer; + /* v8 ignore next 3 */ + if (consumerLinks?.[index] !== link) { + throw new Error('assert failed: invalid indexInProducer'); + } + // swap with the last item to avoid shifting the array + const lastConsumerLink = consumerLinks.pop()!; + const isLast = link === lastConsumerLink; + if (!isLast) { + consumerLinks[index] = lastConsumerLink; + lastConsumerLink.indexInProducer = index; + } else if (index === 0) { + this.checkUnused(); + } + } + + checkUnused(): void {} + updateValue(): void {} + + equal(a: T, b: T): boolean { + const equalFn = this.equalFn; + return equalFn(a, b); + } + + protected increaseEpoch(): void { + epoch++; + this.markConsumersDirty(); + } + + set(newValue: T): void { + checkNotInNotificationPhase(); + const same = this.equal(this.value, newValue); + if (!same) { + batch(() => { + this.value = newValue; + this.version++; + this.equalCache = null; + this.increaseEpoch(); + }); + } + } + + update(updater: Updater): void { + this.set(updater(this.value)); + } + + markConsumersDirty(): void { + const prevNotificationPhase = notificationPhase; + notificationPhase = true; + try { + const consumerLinks = this.consumerLinks; + if (consumerLinks) { + for (let i = 0, l = consumerLinks.length; i < l; i++) { + const link = consumerLinks[i]; + if (link.skipMarkDirty) continue; + link.consumer?.markDirty?.(); + } + } + } finally { + notificationPhase = prevNotificationPhase; + } + } + + get(): T { + checkNotInNotificationPhase(); + if (activeConsumer) { + return activeConsumer.addProducer(this); + } else { + return this.readValue(); + } + } + + readValue(): T { + return this.value; + } + + subscribe(subscriber: Subscriber): UnsubscribeFunction & UnsubscribeObject { + checkNotInNotificationPhase(); + const subscription = new SubscribeConsumer(this, subscriber); + const unsubscriber = () => subscription.unsubscribe(); + unsubscriber.unsubscribe = unsubscriber; + return unsubscriber; + } +} diff --git a/src/internal/subscribeConsumer.ts b/src/internal/subscribeConsumer.ts new file mode 100644 index 0000000..2a54ba2 --- /dev/null +++ b/src/internal/subscribeConsumer.ts @@ -0,0 +1,64 @@ +import type { Subscriber, SubscriberObject } from '../types'; +import { subscribersQueue } from './batch'; +import { updateLinkProducerValue, type BaseLink, type Consumer, type RawStore } from './store'; + +export const noop = (): void => {}; + +const bind = (object: T | null | undefined, fnName: keyof T) => { + const fn = object ? object[fnName] : null; + return typeof fn === 'function' ? fn.bind(object) : noop; +}; + +const noopSubscriber: SubscriberObject = { + next: noop, + pause: noop, + resume: noop, +}; + +export const toSubscriberObject = (subscriber: Subscriber): SubscriberObject => ({ + next: typeof subscriber === 'function' ? subscriber.bind(null) : bind(subscriber, 'next'), + pause: bind(subscriber, 'pause'), + resume: bind(subscriber, 'resume'), +}); + +export class SubscribeConsumer> implements Consumer { + link: Link; + subscriber: SubscriberObject; + dirtyCount = 1; + constructor(producer: RawStore, subscriber: Subscriber) { + this.subscriber = toSubscriberObject(subscriber); + this.link = producer.registerConsumer(producer.newLink(this)); + this.notify(true); + } + + unsubscribe(): void { + if (this.subscriber !== noopSubscriber) { + this.subscriber = noopSubscriber; + this.link.producer.unregisterConsumer(this.link); + } + } + + markDirty(): void { + this.dirtyCount++; + subscribersQueue.push(this); + if (this.dirtyCount === 1) { + this.subscriber.pause(); + } + } + + notify(first = false): void { + this.dirtyCount--; + if (this.dirtyCount === 0 && this.subscriber !== noopSubscriber) { + const link = this.link; + const producer = link.producer; + updateLinkProducerValue(link); + if (producer.isLinkUpToDate(link) && !first) { + this.subscriber.resume(); + } else { + // note that the following line can throw + const value = producer.updateLink(link); + this.subscriber.next(value); + } + } + } +} diff --git a/src/internal/untrack.ts b/src/internal/untrack.ts index 77dd724..be236a0 100644 --- a/src/internal/untrack.ts +++ b/src/internal/untrack.ts @@ -1,7 +1,7 @@ -import type { RawStore } from './store'; +import type { BaseLink, RawStore } from './store'; export interface ActiveConsumer { - addProducer: (store: RawStore) => T; + addProducer: >(store: RawStore) => T; } export let activeConsumer: ActiveConsumer | null = null; From 68ef7d3882c2c79366b2246148e5518a6a828f74 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Tue, 12 Nov 2024 10:08:27 +0100 Subject: [PATCH 03/18] Code review changes --- src/internal/storeComputed.ts | 3 +-- src/internal/storeConst.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/internal/storeComputed.ts b/src/internal/storeComputed.ts index 3225a1d..b18311f 100644 --- a/src/internal/storeComputed.ts +++ b/src/internal/storeComputed.ts @@ -56,8 +56,7 @@ export class RawStoreComputed let link = producerLinks[producerIndex] as L | undefined; if (link?.producer !== producer) { if (link) { - const endIndex = Math.max(producerIndex + 1, producerLinks.length); - producerLinks[endIndex] = link; // push the existing link at the end (to be removed later) + producerLinks.push(link); // push the existing link at the end (to be removed later) } link = producer.registerConsumer(producer.newLink(this)); } diff --git a/src/internal/storeConst.ts b/src/internal/storeConst.ts index e1a4a15..6e99cf0 100644 --- a/src/internal/storeConst.ts +++ b/src/internal/storeConst.ts @@ -18,7 +18,7 @@ export class RawStoreConst implements RawStore> { return link; } unregisterConsumer(_link: BaseLink): void {} - updateValue(_link?: BaseLink | undefined): void {} + updateValue(): void {} isLinkUpToDate(_link: BaseLink): boolean { return true; } From 41233d14edeab37f893601d0d185fbc6908619fb Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Tue, 12 Nov 2024 13:44:59 +0100 Subject: [PATCH 04/18] Enabling dynamic benchmark --- benchmarks/js-reactivity-benchmarks/dynamic.bench.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/benchmarks/js-reactivity-benchmarks/dynamic.bench.ts b/benchmarks/js-reactivity-benchmarks/dynamic.bench.ts index 3bf866d..536f0f3 100644 --- a/benchmarks/js-reactivity-benchmarks/dynamic.bench.ts +++ b/benchmarks/js-reactivity-benchmarks/dynamic.bench.ts @@ -309,8 +309,7 @@ const perfTests = [ for (const config of perfTests) { const { graph, counter } = makeGraph(config); - // FIXME: remove .skip when tansu is faster - bench.skip( + bench( `dynamic ${config.name}`, () => { counter.count = 0; From 53c3c20bd01e7442178328f62962dc072fdb3140 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Tue, 12 Nov 2024 13:47:04 +0100 Subject: [PATCH 05/18] Fixing added eslint rule for jsonArrayReporter --- benchmarks/jsonArrayReporter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/benchmarks/jsonArrayReporter.ts b/benchmarks/jsonArrayReporter.ts index 1957ce4..045901a 100644 --- a/benchmarks/jsonArrayReporter.ts +++ b/benchmarks/jsonArrayReporter.ts @@ -1,5 +1,5 @@ -import { RunnerTestFile } from 'vitest'; -import { Reporter } from 'vitest/reporters'; +import type { RunnerTestFile } from 'vitest'; +import type { Reporter } from 'vitest/reporters'; import { writeFile } from 'fs/promises'; class JsonArrayReporter implements Reporter { From 0eeead2fba10172cc3ddfd3a21d2906a1c9b1725 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Fri, 15 Nov 2024 10:54:49 +0100 Subject: [PATCH 06/18] Adding asserts to make sure a store is no longer dirty after updating it --- src/internal/store.ts | 4 ++++ src/internal/storeTrackingUsage.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/internal/store.ts b/src/internal/store.ts index 5bb0832..eb12b02 100644 --- a/src/internal/store.ts +++ b/src/internal/store.ts @@ -37,6 +37,10 @@ export const updateLinkProducerValue = (link: BaseLink): void => { try { link.skipMarkDirty = true; link.producer.updateValue(); + /* v8 ignore next 3 */ + if (link.producer.flags & RawStoreFlags.DIRTY) { + throw new Error('assert failed: store still dirty after updating it'); + } } finally { link.skipMarkDirty = false; } diff --git a/src/internal/storeTrackingUsage.ts b/src/internal/storeTrackingUsage.ts index b2a571c..0de6184 100644 --- a/src/internal/storeTrackingUsage.ts +++ b/src/internal/storeTrackingUsage.ts @@ -74,6 +74,10 @@ export abstract class RawStoreTrackingUsage extends RawStoreWritable { this.flags |= RawStoreFlags.INSIDE_GET; try { this.updateValue(); + /* v8 ignore next 3 */ + if (this.flags & RawStoreFlags.DIRTY) { + throw new Error('assert failed: store still dirty after updating it'); + } return this.readValue(); } finally { this.flags &= ~RawStoreFlags.INSIDE_GET; From 3150200a4f950562ffbbce0de101f97c87c1c65c Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Fri, 22 Nov 2024 17:25:10 +0100 Subject: [PATCH 07/18] Update to take into account some code review comments --- src/internal/store.ts | 4 +++- src/internal/storeComputedOrDerived.ts | 2 ++ src/internal/storeConst.ts | 9 ++++++--- src/internal/storeDerived.ts | 4 ++-- src/internal/storeSubscribable.ts | 7 ++++--- src/internal/storeTrackingUsage.ts | 8 ++++++++ src/internal/storeWithOnUse.ts | 2 +- src/internal/storeWritable.ts | 24 ++++++++++++------------ src/internal/subscribeConsumer.ts | 2 +- 9 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/internal/store.ts b/src/internal/store.ts index eb12b02..e5de2ca 100644 --- a/src/internal/store.ts +++ b/src/internal/store.ts @@ -24,7 +24,7 @@ export interface BaseLink { export interface RawStore = BaseLink> extends SignalStore, SubscribableStore { - flags: RawStoreFlags; + readonly flags: RawStoreFlags; newLink(consumer: Consumer): Link; registerConsumer(link: Link): Link; unregisterConsumer(link: Link): void; @@ -37,6 +37,8 @@ export const updateLinkProducerValue = (link: BaseLink): void => { try { link.skipMarkDirty = true; link.producer.updateValue(); + // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) + // there should be no way to trigger this error. /* v8 ignore next 3 */ if (link.producer.flags & RawStoreFlags.DIRTY) { throw new Error('assert failed: store still dirty after updating it'); diff --git a/src/internal/storeComputedOrDerived.ts b/src/internal/storeComputedOrDerived.ts index 06393d5..a01bf57 100644 --- a/src/internal/storeComputedOrDerived.ts +++ b/src/internal/storeComputedOrDerived.ts @@ -36,6 +36,8 @@ export abstract class RawStoreComputedOrDerived if (value === COMPUTED_ERRORED) { throw this.error; } + // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) + // there should be no way to trigger this error. /* v8 ignore next 3 */ if (value === COMPUTED_UNSET) { throw new Error('assert failed: computed value is not set'); diff --git a/src/internal/storeConst.ts b/src/internal/storeConst.ts index 6e99cf0..f7e6439 100644 --- a/src/internal/storeConst.ts +++ b/src/internal/storeConst.ts @@ -2,11 +2,10 @@ import type { Subscriber, Unsubscriber } from '../types'; import type { BaseLink, Consumer, RawStore } from './store'; import { RawStoreFlags } from './store'; import { checkNotInNotificationPhase } from './storeWritable'; -import { toSubscriberObject } from './subscribeConsumer'; import { noopUnsubscribe } from './unsubscribe'; export class RawStoreConst implements RawStore> { - flags = RawStoreFlags.NONE; + readonly flags = RawStoreFlags.NONE; constructor(public readonly value: T) {} newLink(_consumer: Consumer): BaseLink { @@ -31,7 +30,11 @@ export class RawStoreConst implements RawStore> { } subscribe(subscriber: Subscriber): Unsubscriber { checkNotInNotificationPhase(); - toSubscriberObject(subscriber).next(this.value); + if (typeof subscriber === 'function') { + subscriber(this.value); + } else { + subscriber?.next?.(this.value); + } return noopUnsubscribe; } } diff --git a/src/internal/storeDerived.ts b/src/internal/storeDerived.ts index c1851d6..57468ac 100644 --- a/src/internal/storeDerived.ts +++ b/src/internal/storeDerived.ts @@ -111,7 +111,7 @@ export class RawStoreDerivedStore extends RawStoreDeri export class RawStoreSyncDerived extends RawStoreDerived { constructor( stores: S, - initialValue: T, + _initialValue: T, public deriveFn: SyncDeriveFn ) { super(stores, COMPUTED_UNSET); @@ -130,7 +130,7 @@ export const createOnUseArg = (store: RawStoreWritable): OnUseArgument }; export class RawStoreAsyncDerived extends RawStoreDerived { - setFn = createOnUseArg(this); + private readonly setFn = createOnUseArg(this); constructor( stores: S, initialValue: T, diff --git a/src/internal/storeSubscribable.ts b/src/internal/storeSubscribable.ts index a6178b2..a86264f 100644 --- a/src/internal/storeSubscribable.ts +++ b/src/internal/storeSubscribable.ts @@ -9,11 +9,12 @@ import { RawStoreTrackingUsage } from './storeTrackingUsage'; import { normalizeUnsubscribe } from './unsubscribe'; export class RawSubscribableWrapper extends RawStoreTrackingUsage { - subscriber: Pick, 'next'> & SubscriberFunction = this.createSubscriber(); - unsubscribe: UnsubscribeFunction | null = null; + private readonly subscriber: Pick, 'next'> & SubscriberFunction = + this.createSubscriber(); + private unsubscribe: UnsubscribeFunction | null = null; override flags = RawStoreFlags.HAS_VISIBLE_ONUSE; - constructor(public subscribable: SubscribableStore) { + constructor(public readonly subscribable: SubscribableStore) { super(undefined as any); } diff --git a/src/internal/storeTrackingUsage.ts b/src/internal/storeTrackingUsage.ts index 0de6184..f41bbb8 100644 --- a/src/internal/storeTrackingUsage.ts +++ b/src/internal/storeTrackingUsage.ts @@ -6,6 +6,8 @@ let flushUnusedQueue: RawStoreTrackingUsage[] | null = null; let inFlushUnused = false; export const flushUnused = (): void => { + // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) + // there should be no way to trigger this error. /* v8 ignore next 3 */ if (inFlushUnused) { throw new Error('assert failed: recursive flushUnused call'); @@ -33,6 +35,8 @@ export abstract class RawStoreTrackingUsage extends RawStoreWritable { override updateValue(): void { const flags = this.flags; if (!(flags & RawStoreFlags.START_USE_CALLED)) { + // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) + // there should be no way to trigger this error. /* v8 ignore next 3 */ if (!(flags & RawStoreFlags.INSIDE_GET) && !this.consumerLinks?.length) { throw new Error('assert failed: untracked producer usage'); @@ -44,6 +48,8 @@ export abstract class RawStoreTrackingUsage extends RawStoreWritable { override checkUnused(): void { const flags = this.flags; + // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) + // there should be no way to trigger this error. /* v8 ignore next 3 */ if (flags & RawStoreFlags.INSIDE_GET) { throw new Error('assert failed: INSIDE_GET flag in checkUnused'); @@ -74,6 +80,8 @@ export abstract class RawStoreTrackingUsage extends RawStoreWritable { this.flags |= RawStoreFlags.INSIDE_GET; try { this.updateValue(); + // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) + // there should be no way to trigger this error. /* v8 ignore next 3 */ if (this.flags & RawStoreFlags.DIRTY) { throw new Error('assert failed: store still dirty after updating it'); diff --git a/src/internal/storeWithOnUse.ts b/src/internal/storeWithOnUse.ts index d3881f2..e0813f3 100644 --- a/src/internal/storeWithOnUse.ts +++ b/src/internal/storeWithOnUse.ts @@ -4,7 +4,7 @@ import { RawStoreTrackingUsage } from './storeTrackingUsage'; import { normalizeUnsubscribe } from './unsubscribe'; export class RawStoreWithOnUse extends RawStoreTrackingUsage { - cleanUpFn: UnsubscribeFunction | null = null; + private cleanUpFn: UnsubscribeFunction | null = null; override flags = RawStoreFlags.HAS_VISIBLE_ONUSE; constructor( diff --git a/src/internal/storeWritable.ts b/src/internal/storeWritable.ts index ae2c404..7202a59 100644 --- a/src/internal/storeWritable.ts +++ b/src/internal/storeWritable.ts @@ -28,9 +28,9 @@ export interface ProducerConsumerLink { export class RawStoreWritable implements RawStore> { constructor(public value: T) {} flags = RawStoreFlags.NONE; - version = 0; + private version = 0; equalFn = equal; - equalCache: Record | null = null; + private equalCache: Record | null = null; consumerLinks: null | ProducerConsumerLink[] = null; newLink(consumer: Consumer): ProducerConsumerLink { @@ -51,13 +51,15 @@ export class RawStoreWritable implements RawStore> if (link.version === this.version - 1 || link.version < 0) { return false; } - if (!this.equalCache) { - this.equalCache = {}; + let equalCache = this.equalCache; + if (!equalCache) { + equalCache = {}; + this.equalCache = equalCache; } - let res = this.equalCache[link.version]; + let res = equalCache[link.version]; if (res === undefined) { res = this.equal(link.value, this.value); - this.equalCache[link.version] = res; + equalCache[link.version] = res; } return res; } @@ -83,6 +85,8 @@ export class RawStoreWritable implements RawStore> unregisterConsumer(link: ProducerConsumerLink): void { const consumerLinks = this.consumerLinks; const index = link.indexInProducer; + // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) + // there should be no way to trigger this error. /* v8 ignore next 3 */ if (consumerLinks?.[index] !== link) { throw new Error('assert failed: invalid indexInProducer'); @@ -137,7 +141,7 @@ export class RawStoreWritable implements RawStore> for (let i = 0, l = consumerLinks.length; i < l; i++) { const link = consumerLinks[i]; if (link.skipMarkDirty) continue; - link.consumer?.markDirty?.(); + link.consumer.markDirty(); } } } finally { @@ -147,11 +151,7 @@ export class RawStoreWritable implements RawStore> get(): T { checkNotInNotificationPhase(); - if (activeConsumer) { - return activeConsumer.addProducer(this); - } else { - return this.readValue(); - } + return activeConsumer ? activeConsumer.addProducer(this) : this.readValue(); } readValue(): T { diff --git a/src/internal/subscribeConsumer.ts b/src/internal/subscribeConsumer.ts index 2a54ba2..87087fc 100644 --- a/src/internal/subscribeConsumer.ts +++ b/src/internal/subscribeConsumer.ts @@ -15,7 +15,7 @@ const noopSubscriber: SubscriberObject = { resume: noop, }; -export const toSubscriberObject = (subscriber: Subscriber): SubscriberObject => ({ +const toSubscriberObject = (subscriber: Subscriber): SubscriberObject => ({ next: typeof subscriber === 'function' ? subscriber.bind(null) : bind(subscriber, 'next'), pause: bind(subscriber, 'pause'), resume: bind(subscriber, 'resume'), From a29123b750f8aa7a8e383057ba36fc04191d1cb7 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Mon, 25 Nov 2024 18:47:41 +0100 Subject: [PATCH 08/18] Adding tests regarding the scope --- src/index.spec.ts | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/index.spec.ts b/src/index.spec.ts index 1f8a922..13e7d63 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -957,6 +957,22 @@ describe('stores', () => { unsubscribe(); }); + + it('should have no scope in the readable onUse and update functions', () => { + const scopes: any[] = []; + const a = readable(0, function (this: any, set) { + scopes.push(this); + set.update(function (this: any, v) { + scopes.push(this); + return v + 1; + }); + return function (this: any) { + scopes.push(this); + }; + }); + expect(a()).toBe(1); + expect(scopes).toEqual([undefined, undefined, undefined]); + }); }); describe('writable', () => { @@ -1199,6 +1215,22 @@ describe('stores', () => { expect((readonlyStore as any).update).toBeUndefined(); expect(readonlyStore[Symbol.observable || '@@observable']()).toBe(readonlyStore); }); + + it('should have no scope in the writable onUse and update functions', () => { + const scopes: any[] = []; + const a = writable(0, function (this: any, set) { + scopes.push(this); + set.update(function (this: any, v) { + scopes.push(this); + return v + 1; + }); + return function (this: any) { + scopes.push(this); + }; + }); + expect(a()).toBe(1); + expect(scopes).toEqual([undefined, undefined, undefined]); + }); }); describe('asWritable', () => { @@ -2267,6 +2299,42 @@ describe('stores', () => { expect(a).toEqual([1, 2, 1, 5, 6]); unsubscribe(); }); + + it('should have no scope in the sync derived function', () => { + const scopes: any[] = []; + const a = writable(0); + const b = derived( + a, + function (this: any, a) { + scopes.push(this); + return a + 1; + }, + 0 + ); + expect(b()).toBe(1); + expect(scopes).toEqual([undefined]); + }); + + it('should have no scope in the async derived functions', () => { + const scopes: any[] = []; + const a = writable(0); + const b = derived( + a, + function (this: any, a, set) { + scopes.push(this); + set.update(function (this: any, v) { + scopes.push(this); + return v + a + 1; + }); + return function (this: any) { + scopes.push(this); + }; + }, + 0 + ); + expect(b()).toBe(1); + expect(scopes).toEqual([undefined, undefined, undefined]); + }); }); describe('batch', () => { From 82754585a867793c7ecbf99dcc2ef011b5700ea8 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Mon, 25 Nov 2024 18:54:33 +0100 Subject: [PATCH 09/18] Simplifying the code of RawDerivedStore --- src/internal/storeDerived.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/internal/storeDerived.ts b/src/internal/storeDerived.ts index 57468ac..7252188 100644 --- a/src/internal/storeDerived.ts +++ b/src/internal/storeDerived.ts @@ -97,15 +97,10 @@ export class RawStoreDerivedStore extends RawStoreDeri constructor( stores: S, initialValue: T, - public deriveFn: (values: StoresInputValues) => void + public derive: (values: StoresInputValues) => void ) { super(stores, initialValue); } - - protected override derive(values: StoresInputValues): void { - const deriveFn = this.deriveFn; - return deriveFn(values); - } } export class RawStoreSyncDerived extends RawStoreDerived { From f9376a86be26868aba4b86b6ceff3ecc87658683 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Mon, 25 Nov 2024 19:01:21 +0100 Subject: [PATCH 10/18] Taking into account code review comments --- src/internal/storeWithOnUse.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/internal/storeWithOnUse.ts b/src/internal/storeWithOnUse.ts index e0813f3..5de86e0 100644 --- a/src/internal/storeWithOnUse.ts +++ b/src/internal/storeWithOnUse.ts @@ -9,14 +9,13 @@ export class RawStoreWithOnUse extends RawStoreTrackingUsage { constructor( value: T, - public onUseFn: () => Unsubscriber | void + public readonly onUseFn: () => Unsubscriber | void ) { super(value); } override startUse(): void { - const onUseFn = this.onUseFn; - this.cleanUpFn = normalizeUnsubscribe(onUseFn()); + this.cleanUpFn = normalizeUnsubscribe(this.onUseFn()); } override endUse(): void { From 5312cb74f415c3a94b089e85caf24d2ec15eb766 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Mon, 25 Nov 2024 19:13:02 +0100 Subject: [PATCH 11/18] Adding tests for the scope of equal --- src/index.spec.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/index.spec.ts b/src/index.spec.ts index 13e7d63..eeb8f0a 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -5,6 +5,7 @@ import { BehaviorSubject, from } from 'rxjs'; import { writable as svelteWritable } from 'svelte/store'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { + OnUseArgument, Readable, ReadableSignal, StoreInput, @@ -973,6 +974,23 @@ describe('stores', () => { expect(a()).toBe(1); expect(scopes).toEqual([undefined, undefined, undefined]); }); + + it('should have no scope in the equal function', () => { + const scopes: any[] = []; + let set: OnUseArgument; + const a = readable(0, { + onUse(s) { + set = s; + }, + equal(a, b) { + scopes.push(this); + return Object.is(a, b); + }, + }); + expect(a()).toBe(0); + set!(1); + expect(scopes).toEqual([undefined]); + }); }); describe('writable', () => { @@ -1231,6 +1249,18 @@ describe('stores', () => { expect(a()).toBe(1); expect(scopes).toEqual([undefined, undefined, undefined]); }); + + it('should have no scope in the equal function', () => { + const scopes: any[] = []; + const a = writable(0, { + equal(a, b) { + scopes.push(this); + return Object.is(a, b); + }, + }); + a.set(1); + expect(scopes).toEqual([undefined]); + }); }); describe('asWritable', () => { @@ -3410,6 +3440,21 @@ describe('stores', () => { expect(scope).toBe(undefined); }); + it('should have no scope in the equal function', () => { + const scopes: any[] = []; + const a = writable(0); + const b = computed(() => a() + 1, { + equal(a, b) { + scopes.push(this); + return Object.is(a, b); + }, + }); + expect(b()).toBe(1); + a.set(1); + expect(b()).toBe(2); + expect(scopes).toEqual([undefined]); + }); + it('should correctly register and clean-up consumers (several clean-up)', async () => { const store = writable(0); const doubleStore = computed(() => store() * 2); From f4b0efa0a0824cac166e20644e01da215110e41c Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Tue, 26 Nov 2024 10:00:34 +0100 Subject: [PATCH 12/18] Adding tests --- src/index.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/index.spec.ts b/src/index.spec.ts index eeb8f0a..3b1e383 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -784,6 +784,24 @@ describe('stores', () => { unsubscribe(); }); + it('should work to call the subscribe method of a constant store with a function', () => { + const one = readable(1); + const values: number[] = []; + one.subscribe((value) => values.push(value)); + expect(values).toEqual([1]); + }); + + it('should work to call the subscribe method of a constant store with an object with a next method', () => { + const one = readable(1); + const values: number[] = []; + one.subscribe({ + next(value) { + values.push(value); + }, + }); + expect(values).toEqual([1]); + }); + it('should work to subscribe without a listener', () => { let used = 0; const a = readable(0, () => { From 46a7b7e53eae881bdf84b034316dfffae7930807 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Tue, 26 Nov 2024 15:27:55 +0100 Subject: [PATCH 13/18] Marking class members as private or readonly as much as possible --- src/internal/storeComputed.ts | 8 ++++---- src/internal/storeConst.ts | 2 +- src/internal/storeDerived.ts | 14 +++++++------- src/internal/storeSubscribable.ts | 2 +- src/internal/storeWithOnUse.ts | 2 +- src/internal/storeWritable.ts | 8 ++++---- src/internal/subscribeConsumer.ts | 4 ++-- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/internal/storeComputed.ts b/src/internal/storeComputed.ts index b18311f..81eb578 100644 --- a/src/internal/storeComputed.ts +++ b/src/internal/storeComputed.ts @@ -12,11 +12,11 @@ export class RawStoreComputed extends RawStoreComputedOrDerived implements Consumer, ActiveConsumer { - producerIndex = 0; - producerLinks: BaseLink[] | null = null; - epoch = -1; + private producerIndex = 0; + private producerLinks: BaseLink[] | null = null; + private epoch = -1; - constructor(public computeFn: () => T) { + constructor(private readonly computeFn: () => T) { super(COMPUTED_UNSET); } diff --git a/src/internal/storeConst.ts b/src/internal/storeConst.ts index f7e6439..a1148db 100644 --- a/src/internal/storeConst.ts +++ b/src/internal/storeConst.ts @@ -6,7 +6,7 @@ import { noopUnsubscribe } from './unsubscribe'; export class RawStoreConst implements RawStore> { readonly flags = RawStoreFlags.NONE; - constructor(public readonly value: T) {} + constructor(private readonly value: T) {} newLink(_consumer: Consumer): BaseLink { return { diff --git a/src/internal/storeDerived.ts b/src/internal/storeDerived.ts index 7252188..18e414a 100644 --- a/src/internal/storeDerived.ts +++ b/src/internal/storeDerived.ts @@ -23,10 +23,10 @@ abstract class RawStoreDerived extends RawStoreComputedOrDerived implements Consumer { - arrayMode: boolean; - producers: RawStore[]; - producerLinks: BaseLink[] | null = null; - cleanUpFn: UnsubscribeFunction | null = null; + private readonly arrayMode: boolean; + private readonly producers: RawStore[]; + private producerLinks: BaseLink[] | null = null; + private cleanUpFn: UnsubscribeFunction | null = null; override flags = RawStoreFlags.HAS_VISIBLE_ONUSE | RawStoreFlags.DIRTY; constructor(producers: S, initialValue: T) { @@ -97,7 +97,7 @@ export class RawStoreDerivedStore extends RawStoreDeri constructor( stores: S, initialValue: T, - public derive: (values: StoresInputValues) => void + protected readonly derive: (values: StoresInputValues) => void ) { super(stores, initialValue); } @@ -107,7 +107,7 @@ export class RawStoreSyncDerived extends RawStoreDeriv constructor( stores: S, _initialValue: T, - public deriveFn: SyncDeriveFn + private readonly deriveFn: SyncDeriveFn ) { super(stores, COMPUTED_UNSET); } @@ -129,7 +129,7 @@ export class RawStoreAsyncDerived extends RawStoreDeri constructor( stores: S, initialValue: T, - public deriveFn: AsyncDeriveFn + private readonly deriveFn: AsyncDeriveFn ) { super(stores, initialValue); } diff --git a/src/internal/storeSubscribable.ts b/src/internal/storeSubscribable.ts index a86264f..67a8534 100644 --- a/src/internal/storeSubscribable.ts +++ b/src/internal/storeSubscribable.ts @@ -14,7 +14,7 @@ export class RawSubscribableWrapper extends RawStoreTrackingUsage { private unsubscribe: UnsubscribeFunction | null = null; override flags = RawStoreFlags.HAS_VISIBLE_ONUSE; - constructor(public readonly subscribable: SubscribableStore) { + constructor(private readonly subscribable: SubscribableStore) { super(undefined as any); } diff --git a/src/internal/storeWithOnUse.ts b/src/internal/storeWithOnUse.ts index 5de86e0..81f63d4 100644 --- a/src/internal/storeWithOnUse.ts +++ b/src/internal/storeWithOnUse.ts @@ -9,7 +9,7 @@ export class RawStoreWithOnUse extends RawStoreTrackingUsage { constructor( value: T, - public readonly onUseFn: () => Unsubscriber | void + private readonly onUseFn: () => Unsubscriber | void ) { super(value); } diff --git a/src/internal/storeWritable.ts b/src/internal/storeWritable.ts index 7202a59..a9ae409 100644 --- a/src/internal/storeWritable.ts +++ b/src/internal/storeWritable.ts @@ -26,7 +26,7 @@ export interface ProducerConsumerLink { } export class RawStoreWritable implements RawStore> { - constructor(public value: T) {} + constructor(protected value: T) {} flags = RawStoreFlags.NONE; private version = 0; equalFn = equal; @@ -102,10 +102,10 @@ export class RawStoreWritable implements RawStore> } } - checkUnused(): void {} + protected checkUnused(): void {} updateValue(): void {} - equal(a: T, b: T): boolean { + protected equal(a: T, b: T): boolean { const equalFn = this.equalFn; return equalFn(a, b); } @@ -132,7 +132,7 @@ export class RawStoreWritable implements RawStore> this.set(updater(this.value)); } - markConsumersDirty(): void { + protected markConsumersDirty(): void { const prevNotificationPhase = notificationPhase; notificationPhase = true; try { diff --git a/src/internal/subscribeConsumer.ts b/src/internal/subscribeConsumer.ts index 87087fc..78c49a5 100644 --- a/src/internal/subscribeConsumer.ts +++ b/src/internal/subscribeConsumer.ts @@ -22,8 +22,8 @@ const toSubscriberObject = (subscriber: Subscriber): SubscriberObject = }); export class SubscribeConsumer> implements Consumer { - link: Link; - subscriber: SubscriberObject; + private readonly link: Link; + private subscriber: SubscriberObject; dirtyCount = 1; constructor(producer: RawStore, subscriber: Subscriber) { this.subscriber = toSubscriberObject(subscriber); From 348842c38b82ce861e2596635d14e8316a8d511e Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Tue, 26 Nov 2024 15:56:42 +0100 Subject: [PATCH 14/18] Adding jsdoc for SignalStore --- src/types.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/types.ts b/src/types.ts index f5e5ba8..110cdd5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -91,6 +91,9 @@ export interface InteropObservable { */ export type StoreInput = SubscribableStore | InteropObservable; +/** + * Represents a store that can return its value with a get method. + */ export interface SignalStore { /** * Returns the value of the store. From 0c5748b81549c8219e6c2ddc208734874e5d66b3 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Tue, 26 Nov 2024 16:09:37 +0100 Subject: [PATCH 15/18] Making producerLinks non-lazy in RawStoreComputed --- src/internal/storeComputed.ts | 45 ++++++++++++++--------------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/src/internal/storeComputed.ts b/src/internal/storeComputed.ts index 81eb578..1bc94f9 100644 --- a/src/internal/storeComputed.ts +++ b/src/internal/storeComputed.ts @@ -13,7 +13,7 @@ export class RawStoreComputed implements Consumer, ActiveConsumer { private producerIndex = 0; - private producerLinks: BaseLink[] | null = null; + private producerLinks: BaseLink[] = []; private epoch = -1; constructor(private readonly computeFn: () => T) { @@ -47,11 +47,7 @@ export class RawStoreComputed } addProducer>(producer: RawStore): U { - let producerLinks = this.producerLinks; - if (!producerLinks) { - producerLinks = []; - this.producerLinks = producerLinks; - } + const producerLinks = this.producerLinks; const producerIndex = this.producerIndex; let link = producerLinks[producerIndex] as L | undefined; if (link?.producer !== producer) { @@ -71,38 +67,33 @@ export class RawStoreComputed override startUse(): void { const producerLinks = this.producerLinks; - if (producerLinks) { - for (let i = 0, l = producerLinks.length; i < l; i++) { - const link = producerLinks[i]; - link.producer.registerConsumer(link); - } + for (let i = 0, l = producerLinks.length; i < l; i++) { + const link = producerLinks[i]; + link.producer.registerConsumer(link); } this.flags |= RawStoreFlags.DIRTY; } override endUse(): void { const producerLinks = this.producerLinks; - if (producerLinks) { - for (let i = 0, l = producerLinks.length; i < l; i++) { - const link = producerLinks[i]; - link.producer.unregisterConsumer(link); - } + for (let i = 0, l = producerLinks.length; i < l; i++) { + const link = producerLinks[i]; + link.producer.unregisterConsumer(link); } } override areProducersUpToDate(): boolean { + if (this.value === COMPUTED_UNSET) { + return false; + } const producerLinks = this.producerLinks; - if (producerLinks) { - for (let i = 0, l = producerLinks.length; i < l; i++) { - const link = producerLinks[i]; - const producer = link.producer; - updateLinkProducerValue(link); - if (!producer.isLinkUpToDate(link)) { - return false; - } + for (let i = 0, l = producerLinks.length; i < l; i++) { + const link = producerLinks[i]; + const producer = link.producer; + updateLinkProducerValue(link); + if (!producer.isLinkUpToDate(link)) { + return false; } - } else if (this.value === COMPUTED_UNSET) { - return false; } return true; } @@ -125,7 +116,7 @@ export class RawStoreComputed // Remove unused producers: const producerLinks = this.producerLinks; const producerIndex = this.producerIndex; - if (producerLinks && producerIndex < producerLinks.length) { + if (producerIndex < producerLinks.length) { for (let i = 0, l = producerLinks.length - producerIndex; i < l; i++) { const link = producerLinks.pop()!; link.producer.unregisterConsumer(link); From 3720d364013b0f75eb0a4f988d8492250f304ef0 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Tue, 26 Nov 2024 16:15:02 +0100 Subject: [PATCH 16/18] Making consumerLinks non-lazy in RawStoreWritable --- src/index.spec.ts | 2 +- src/internal/storeTrackingUsage.ts | 4 ++-- src/internal/storeWritable.ts | 20 +++++++------------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 3b1e383..e57d910 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -37,7 +37,7 @@ import type { RawStoreWritable } from './internal/storeWritable'; const expectCorrectlyCleanedUp = (store: StoreInput) => { const rawStore = (store as any)[rawStoreSymbol] as RawStoreWritable; - expect(rawStore.consumerLinks?.length ?? 0).toBe(0); + expect(rawStore.consumerLinks.length).toBe(0); expect(rawStore.flags & RawStoreFlags.START_USE_CALLED).toBeFalsy(); }; diff --git a/src/internal/storeTrackingUsage.ts b/src/internal/storeTrackingUsage.ts index f41bbb8..d96d952 100644 --- a/src/internal/storeTrackingUsage.ts +++ b/src/internal/storeTrackingUsage.ts @@ -38,7 +38,7 @@ export abstract class RawStoreTrackingUsage extends RawStoreWritable { // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) // there should be no way to trigger this error. /* v8 ignore next 3 */ - if (!(flags & RawStoreFlags.INSIDE_GET) && !this.consumerLinks?.length) { + if (!(flags & RawStoreFlags.INSIDE_GET) && !this.consumerLinks.length) { throw new Error('assert failed: untracked producer usage'); } this.flags |= RawStoreFlags.START_USE_CALLED; @@ -54,7 +54,7 @@ export abstract class RawStoreTrackingUsage extends RawStoreWritable { if (flags & RawStoreFlags.INSIDE_GET) { throw new Error('assert failed: INSIDE_GET flag in checkUnused'); } - if (flags & RawStoreFlags.START_USE_CALLED && !this.consumerLinks?.length) { + if (flags & RawStoreFlags.START_USE_CALLED && !this.consumerLinks.length) { if (inFlushUnused || flags & RawStoreFlags.HAS_VISIBLE_ONUSE) { this.flags &= ~RawStoreFlags.START_USE_CALLED; untrack(() => this.endUse()); diff --git a/src/internal/storeWritable.ts b/src/internal/storeWritable.ts index a9ae409..9b0644c 100644 --- a/src/internal/storeWritable.ts +++ b/src/internal/storeWritable.ts @@ -31,7 +31,7 @@ export class RawStoreWritable implements RawStore> private version = 0; equalFn = equal; private equalCache: Record | null = null; - consumerLinks: null | ProducerConsumerLink[] = null; + consumerLinks: ProducerConsumerLink[] = []; newLink(consumer: Consumer): ProducerConsumerLink { return { @@ -71,11 +71,7 @@ export class RawStoreWritable implements RawStore> } registerConsumer(link: ProducerConsumerLink): ProducerConsumerLink { - let consumerLinks = this.consumerLinks; - if (!consumerLinks) { - consumerLinks = []; - this.consumerLinks = consumerLinks; - } + const consumerLinks = this.consumerLinks; const indexInProducer = consumerLinks.length; link.indexInProducer = indexInProducer; consumerLinks[indexInProducer] = link; @@ -88,7 +84,7 @@ export class RawStoreWritable implements RawStore> // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) // there should be no way to trigger this error. /* v8 ignore next 3 */ - if (consumerLinks?.[index] !== link) { + if (consumerLinks[index] !== link) { throw new Error('assert failed: invalid indexInProducer'); } // swap with the last item to avoid shifting the array @@ -137,12 +133,10 @@ export class RawStoreWritable implements RawStore> notificationPhase = true; try { const consumerLinks = this.consumerLinks; - if (consumerLinks) { - for (let i = 0, l = consumerLinks.length; i < l; i++) { - const link = consumerLinks[i]; - if (link.skipMarkDirty) continue; - link.consumer.markDirty(); - } + for (let i = 0, l = consumerLinks.length; i < l; i++) { + const link = consumerLinks[i]; + if (link.skipMarkDirty) continue; + link.consumer.markDirty(); } } finally { notificationPhase = prevNotificationPhase; From bc50b2bac197aa23bca65fbb076b21e46b9b852f Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Tue, 26 Nov 2024 18:22:51 +0100 Subject: [PATCH 17/18] Adding test --- src/index.spec.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/index.spec.ts b/src/index.spec.ts index e57d910..2a4b714 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -3098,6 +3098,29 @@ describe('stores', () => { expect(cHasListeners).toBe(false); }); + it('should not re-subscribe to stores that should no longer be used', () => { + const events: string[] = []; + const a = writable(true); + const b = writable(0, () => { + events.push('b used'); + return () => { + events.push('b unused'); + }; + }); + const c = writable(1, () => { + events.push('c used'); + return () => { + events.push('c unused'); + }; + }); + const d = computed(() => (a() ? b() : c())); + expect(d()).toBe(0); + events.push('changing a'); + a.set(false); + expect(d()).toBe(1); + expect(events).toEqual(['b used', 'b unused', 'changing a', 'c used', 'c unused']); + }); + it('should not recompute if an untracked store changed', () => { const a = writable(1); const b = writable(2); From ed7fec3744b34b22cf81295672c55dea06bbb943 Mon Sep 17 00:00:00 2001 From: David-Emmanuel DIVERNOIS Date: Wed, 27 Nov 2024 08:11:13 +0100 Subject: [PATCH 18/18] Allow reading a store in its onUse callback --- src/index.spec.ts | 9 +++++++++ src/internal/store.ts | 7 +++---- src/internal/storeTrackingUsage.ts | 22 ++++++++-------------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 2a4b714..829a696 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1279,6 +1279,15 @@ describe('stores', () => { a.set(1); expect(scopes).toEqual([undefined]); }); + + it('should allow reading the store in onUse', () => { + const onUseValues: number[] = []; + const store = writable(0, () => { + onUseValues.push(store()); + }); + expect(store()).toBe(0); + expect(onUseValues).toEqual([0]); + }); }); describe('asWritable', () => { diff --git a/src/internal/store.ts b/src/internal/store.ts index e5de2ca..1d65ddc 100644 --- a/src/internal/store.ts +++ b/src/internal/store.ts @@ -9,11 +9,10 @@ export const enum RawStoreFlags { // the following flags are used in RawStoreTrackingUsage and derived classes HAS_VISIBLE_ONUSE = 1, START_USE_CALLED = 1 << 1, - INSIDE_GET = 1 << 2, - FLUSH_PLANNED = 1 << 3, + FLUSH_PLANNED = 1 << 2, // the following flags are used in RawStoreComputedOrDerived and derived classes - COMPUTING = 1 << 4, - DIRTY = 1 << 5, + COMPUTING = 1 << 3, + DIRTY = 1 << 4, } export interface BaseLink { diff --git a/src/internal/storeTrackingUsage.ts b/src/internal/storeTrackingUsage.ts index d96d952..6495145 100644 --- a/src/internal/storeTrackingUsage.ts +++ b/src/internal/storeTrackingUsage.ts @@ -29,6 +29,7 @@ export const flushUnused = (): void => { }; export abstract class RawStoreTrackingUsage extends RawStoreWritable { + private extraUsages = 0; abstract startUse(): void; abstract endUse(): void; @@ -38,7 +39,7 @@ export abstract class RawStoreTrackingUsage extends RawStoreWritable { // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) // there should be no way to trigger this error. /* v8 ignore next 3 */ - if (!(flags & RawStoreFlags.INSIDE_GET) && !this.consumerLinks.length) { + if (!this.extraUsages && !this.consumerLinks.length) { throw new Error('assert failed: untracked producer usage'); } this.flags |= RawStoreFlags.START_USE_CALLED; @@ -48,13 +49,7 @@ export abstract class RawStoreTrackingUsage extends RawStoreWritable { override checkUnused(): void { const flags = this.flags; - // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) - // there should be no way to trigger this error. - /* v8 ignore next 3 */ - if (flags & RawStoreFlags.INSIDE_GET) { - throw new Error('assert failed: INSIDE_GET flag in checkUnused'); - } - if (flags & RawStoreFlags.START_USE_CALLED && !this.consumerLinks.length) { + if (flags & RawStoreFlags.START_USE_CALLED && !this.extraUsages && !this.consumerLinks.length) { if (inFlushUnused || flags & RawStoreFlags.HAS_VISIBLE_ONUSE) { this.flags &= ~RawStoreFlags.START_USE_CALLED; untrack(() => this.endUse()); @@ -74,10 +69,7 @@ export abstract class RawStoreTrackingUsage extends RawStoreWritable { if (activeConsumer) { return activeConsumer.addProducer(this); } else { - if (this.flags & RawStoreFlags.INSIDE_GET) { - throw new Error('recursive computed'); - } - this.flags |= RawStoreFlags.INSIDE_GET; + this.extraUsages++; try { this.updateValue(); // Ignoring coverage for the following lines because, unless there is a bug in tansu (which would have to be fixed!) @@ -88,8 +80,10 @@ export abstract class RawStoreTrackingUsage extends RawStoreWritable { } return this.readValue(); } finally { - this.flags &= ~RawStoreFlags.INSIDE_GET; - this.checkUnused(); + const extraUsages = --this.extraUsages; + if (extraUsages === 0) { + this.checkUnused(); + } } } }