Performance: sprinkle 'onDestroy' everywhere to cleanup old stores; cleanup 'Stores' utility class

This commit is contained in:
Pieter Vander Vennet 2025-08-01 03:07:37 +02:00
parent 66f093afd8
commit 81be4db044
79 changed files with 332 additions and 325 deletions

View file

@ -5,7 +5,7 @@ import { Readable, Subscriber, Unsubscriber, Updater, Writable } from "svelte/st
* Various static utils
*/
export class Stores {
public static Chronic(millis: number, asLong: () => boolean = undefined): Store<Date> {
public static chronic(millis: number, asLong: () => boolean = undefined): Store<Date> {
const source = new UIEventSource<Date>(undefined)
function run() {
@ -23,32 +23,12 @@ export class Stores {
return source
}
public static FromPromiseWithErr<T>(
promise: Promise<T>
): Store<{ success: T } | { error: any }> {
return UIEventSource.FromPromiseWithErr(promise)
}
/**
* Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated.
* If the promise fails, the value will stay undefined
* @param promise
* @constructor
*/
public static FromPromise<T>(promise: Promise<T>): Store<T | undefined> {
const src = new UIEventSource<T>(undefined)
promise
?.catch((err): undefined => {
console.warn("Promise failed:", err)
return undefined
})
?.then((d) => src.setData(d))
return src
}
public static concat<T>(stores: Store<T | undefined>[]): Store<(T | undefined)[]> ;
public static concat<T>(stores: Store<T>[]): Store<T[]> ;
public static concat<T>(stores: Store<T | undefined>[]): Store<(T | undefined)[]> {
const newStore = new UIEventSource<(T | undefined)[]>([])
public static concat<T>(stores: ReadonlyArray<Store<T | undefined>>): Store<(T | undefined)[]> ;
public static concat<T>(stores: ReadonlyArray<Store<T>>): Store<T[]> ;
public static concat<T>(stores: ReadonlyArray<Store<T | undefined>>): Store<(T | undefined)[]> {
const newStore = new UIEventSource<(T | undefined)[]>(
stores.map(store => store?.data),
)
function update() {
if (newStore._callbacks.isDestroyed) {
@ -64,7 +44,6 @@ export class Stores {
for (const store of stores) {
store.addCallback(() => update())
}
update()
return newStore
}
@ -82,7 +61,7 @@ export class Stores {
* @param src
* @constructor
*/
public static ListStabilized<T>(src: Store<T[]>): Store<T[]> {
public static listStabilized<T>(src: Store<T[]>): Store<T[]> {
const stable = new UIEventSource<T[]>(undefined)
src.addCallbackAndRun((list) => {
if (list === undefined) {
@ -110,33 +89,6 @@ export class Stores {
})
return newStore
}
public static fromArray<T>(sources: ReadonlyArray<UIEventSource<T>>): UIEventSource<T[]> {
const src = new UIEventSource<T[]>(sources.map((s) => s.data))
for (let i = 0; i < sources.length; i++) {
sources[i].addCallback((content) => {
src.data[i] = content
src.ping()
})
}
src.addCallbackD((contents) => {
for (let i = 0; i < contents.length; i++) {
sources[i]?.setData(contents[i])
}
})
return src
}
public static fromStoresArray<T>(sources: ReadonlyArray<Store<T>>): Store<T[]> {
const src = new UIEventSource<T[]>(sources.map((s) => s.data))
for (let i = 0; i < sources.length; i++) {
sources[i].addCallback((content) => {
src.data[i] = content
src.ping()
})
}
return src
}
}
export abstract class Store<T> implements Readable<T> {
@ -162,17 +114,26 @@ export abstract class Store<T> implements Readable<T> {
}
abstract map<J>(f: (t: T) => J): Store<J>
abstract map<J>(f: (t: T) => J, extraStoresToWatch: Store<unknown>[]): Store<J>
abstract map<J>(f: (t: T) => J, callbackDestroyFunction: (f: () => void) => void): Store<J>
abstract map<J>(
f: (t: T) => J,
extraStoresToWatch: Store<unknown>[],
callbackDestroyFunction: (f: () => void) => void
callbackDestroyFunction?: (f: () => void) => void,
): Store<J>
public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J,
extraStoresToWatch?: Store<unknown>[],
callbackDestroyFunction?: (f: () => void) => void
callbackDestroyFunction?: (f: () => void) => void,
): Store<J>
public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J,
callbackDestroyFunction?: (f: () => void) => void,
): Store<J>
public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J,
extraStoresToWatch?: Store<unknown>[] | ((f: () => void) => void),
callbackDestroyFunction?: (f: () => void) => void,
): Store<J> {
return this.map(
(t) => {
@ -184,8 +145,8 @@ export abstract class Store<T> implements Readable<T> {
}
return f(<Exclude<T, undefined | null>>t)
},
extraStoresToWatch,
callbackDestroyFunction
typeof extraStoresToWatch === "function" ? [] : extraStoresToWatch,
callbackDestroyFunction ?? (typeof extraStoresToWatch === "function" ? extraStoresToWatch : undefined),
)
}
@ -212,22 +173,6 @@ export abstract class Store<T> implements Readable<T> {
*/
abstract addCallbackAndRun(callback: (data: T) => void): () => void
public withEqualityStabilized(
comparator: (t: T | undefined, t1: T | undefined) => boolean
): Store<T> {
let oldValue = undefined
return this.map((v) => {
if (v == oldValue) {
return oldValue
}
if (comparator(oldValue, v)) {
return oldValue
}
oldValue = v
return v
})
}
/**
* Monadic bind function
*
@ -274,8 +219,9 @@ export abstract class Store<T> implements Readable<T> {
* src.setData(0)
* lastValue // => "def"
*/
public bind<X>(f: (t: T) => Store<X>, extraSources: Store<unknown>[] = []): Store<X> {
const mapped = this.map(f, extraSources)
public bind<X>(f: (t: T) => Store<X>, extraSources?: Store<unknown>[] | ((f: () => void) => void), onDestroy?: (f : () => void) => void): Store<X> {
const mapped = this.map(f, typeof extraSources === "function" ? undefined : extraSources,
onDestroy ?? (typeof extraSources === "function" ? extraSources : undefined))
const sink = new UIEventSource<X>(undefined)
const seenEventSources = new Set<Store<X>>()
mapped.addCallbackAndRun((newEventSource) => {
@ -308,7 +254,8 @@ export abstract class Store<T> implements Readable<T> {
public bindD<X>(
f: (t: Exclude<T, undefined | null>) => Store<X>,
extraSources: Store<unknown>[] = []
extraSources?: Store<unknown>[],
onDestroy?: ((f: () => void) => void)
): Store<X> {
return this.bind((t) => {
if (t === null) {
@ -318,7 +265,7 @@ export abstract class Store<T> implements Readable<T> {
return undefined
}
return f(<Exclude<T, undefined | null>>t)
}, extraSources)
}, extraSources, onDestroy)
}
public stabilized(millisToStabilize): Store<T> {
@ -402,7 +349,8 @@ export class ImmutableStore<T> extends Store<T> {
this.data = data
}
private static readonly pass: () => void = () => {}
private static readonly pass: () => void = () => {
}
addCallback(_: (data: T) => void): () => void {
// pass: data will never change
@ -430,8 +378,8 @@ export class ImmutableStore<T> extends Store<T> {
map<J>(
f: (t: T) => J,
extraStores: Store<any>[] = undefined,
ondestroyCallback?: (f: () => void) => void
extraStores: Store<any>[] | ((f: () => void) => void)= undefined,
ondestroyCallback?: (f: () => void) => void,
): ImmutableStore<J> {
if (extraStores?.length > 0) {
return new MappedStore(this, f, extraStores, undefined, f(this.data), ondestroyCallback)
@ -482,7 +430,6 @@ class ListenerTracker<T> {
public ping(data: T): number {
this.pingCount++
let toDelete = undefined
const startTime = new Date().getTime() / 1000
for (const callback of this._callbacks) {
try {
if (callback(data) === true) {
@ -498,12 +445,6 @@ class ListenerTracker<T> {
console.error("Got an error while running a callback:", e)
}
}
const endTime = new Date().getTime() / 1000
if (endTime - startTime > 500) {
console.trace(
"Warning: a ping took more then 500ms; this is probably a performance issue"
)
}
if (toDelete !== undefined) {
for (const toDeleteElement of toDelete) {
this._callbacks.splice(this._callbacks.indexOf(toDeleteElement), 1)
@ -529,6 +470,11 @@ class MappedStore<TIn, T> extends Store<T> {
private static readonly pass: () => void
private readonly _upstream: Store<TIn>
private readonly _upstreamCallbackHandler: ListenerTracker<TIn> | undefined
/**
* THe last 'pingcount' (aka 'tick') that the upstream did.
* If we get a request for the data, we check if it is still up to date with this
* @private
*/
private _upstreamPingCount: number = -1
private _unregisterFromUpstream: () => void
private readonly _f: (t: TIn) => T
@ -540,10 +486,10 @@ class MappedStore<TIn, T> extends Store<T> {
constructor(
upstream: Store<TIn>,
f: (t: TIn) => T,
extraStores: Store<unknown>[],
extraStores: Store<unknown>[] | ((t: () => void) => void),
upstreamListenerHandler: ListenerTracker<TIn> | undefined,
initialState: T,
onDestroy?: (f: () => void) => void
onDestroy?: (f: () => void) => void,
) {
super()
this._upstream = upstream
@ -551,7 +497,11 @@ class MappedStore<TIn, T> extends Store<T> {
this._f = f
this._data = initialState
this._upstreamPingCount = upstreamListenerHandler?.pingCount
this._extraStores = extraStores
if (typeof extraStores === "function") {
onDestroy ??= extraStores
} else {
this._extraStores = extraStores
}
this.registerCallbacksToUpstream()
if (onDestroy !== undefined) {
onDestroy(() => this.unregisterFromUpstream())
@ -572,7 +522,7 @@ class MappedStore<TIn, T> extends Store<T> {
if (!this._callbacksAreRegistered) {
// Callbacks are not registered, so we haven't been listening for updates from the upstream which might have changed
if (this._upstreamCallbackHandler?.pingCount != this._upstreamPingCount) {
// Upstream has pinged - let's update our data first
// Upstream has pinged at least once - let's update our data first
this._data = this._f(this._upstream.data)
}
return this._data
@ -582,30 +532,34 @@ class MappedStore<TIn, T> extends Store<T> {
map<J>(
f: (t: T) => J,
extraStores: Store<unknown>[] = undefined,
ondestroyCallback?: (f: () => void) => void
extraStores: Store<unknown>[] | ((f: () => void) => void) = undefined,
ondestroyCallback?: (f: () => void) => void,
): Store<J> {
let stores: Store<unknown>[] = undefined
if (extraStores?.length > 0 || this._extraStores?.length > 0) {
stores = []
}
if (extraStores?.length > 0) {
stores?.push(...extraStores)
}
if (this._extraStores?.length > 0) {
this._extraStores?.forEach((store) => {
if (stores.indexOf(store) < 0) {
stores.push(store)
}
})
if (typeof extraStores !== "function") {
if (extraStores?.length > 0 || this._extraStores?.length > 0) {
stores = []
}
if (extraStores?.length > 0) {
stores?.push(...extraStores)
}
if (this._extraStores?.length > 0) {
this._extraStores?.forEach((store) => {
if (stores.indexOf(store) < 0) {
stores.push(store)
}
})
}
}
return new MappedStore(
this,
f, // we could fuse the functions here (e.g. data => f(this._f(data), but this might result in _f being calculated multiple times, breaking things
stores,
this._callbacks,
f(this.data),
ondestroyCallback
ondestroyCallback,
)
}
@ -651,7 +605,12 @@ class MappedStore<TIn, T> extends Store<T> {
}
private unregisterFromUpstream() {
if (!this._callbacksAreRegistered) {
return
}
this._upstreamPingCount = this._upstreamCallbackHandler.pingCount
this._callbacksAreRegistered = false
console.log("Unregistering from upstream", this._upstream)
this._unregisterFromUpstream()
this._unregisterFromExtraStores?.forEach((unr) => unr())
}
@ -659,7 +618,7 @@ class MappedStore<TIn, T> extends Store<T> {
private registerCallbacksToUpstream() {
this._unregisterFromUpstream = this._upstream.addCallback(() => this.update())
this._unregisterFromExtraStores = this._extraStores?.map((store) =>
store?.addCallback(() => this.update())
store?.addCallback(() => this.update()),
)
this._callbacksAreRegistered = true
}
@ -680,7 +639,8 @@ class MappedStore<TIn, T> extends Store<T> {
}
export class UIEventSource<T> extends Store<T> implements Writable<T> {
private static readonly pass: () => void = () => {}
private static readonly pass: () => void = () => {
}
public data: T
_callbacks: ListenerTracker<T> = new ListenerTracker<T>()
@ -695,7 +655,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
public static flatten<X>(
source: Store<Store<X>>,
possibleSources?: Store<object>[]
possibleSources?: Store<object>[],
): UIEventSource<X> {
const sink = new UIEventSource<X>(source.data?.data)
@ -722,9 +682,9 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
* Converts a promise into a UIventsource, sets the UIeventSource when the result is calculated.
* If the promise fails, the value will stay undefined, but 'onError' will be called
*/
public static FromPromise<T>(
public static fromPromise<T>(
promise: Promise<T>,
onError: (e) => void = undefined
onError: (e) => void = undefined,
): UIEventSource<T> {
const src = new UIEventSource<T>(undefined)
promise?.then((d) => src.setData(d))
@ -744,8 +704,8 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
* @param promise
* @constructor
*/
public static FromPromiseWithErr<T>(
promise: Promise<T>
public static fromPromiseWithErr<T>(
promise: Promise<T>,
): UIEventSource<{ success: T } | { error: any } | undefined> {
const src = new UIEventSource<{ success: T } | { error: any }>(undefined)
promise
@ -778,7 +738,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
return undefined
}
return "" + fl
}
},
)
}
@ -809,7 +769,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
return undefined
}
return "" + fl
}
},
)
}
@ -817,13 +777,13 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
return stringUIEventSource.sync(
(str) => str === "true",
[],
(b) => "" + b
(b) => "" + b,
)
}
static asObject<T extends object | string>(
stringUIEventSource: UIEventSource<string>,
defaultV: T
defaultV: T,
): UIEventSource<T> {
return stringUIEventSource.sync(
(str) => {
@ -839,13 +799,13 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
"due to",
e,
"; the underlying data store has tag",
stringUIEventSource.tag
stringUIEventSource.tag,
)
return defaultV
}
},
[],
(b) => JSON.stringify(b) ?? ""
(b) => JSON.stringify(b) ?? "",
)
}
@ -934,8 +894,17 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
*/
public map<J>(
f: (t: T) => J,
extraSources: Store<unknown>[] = [],
onDestroy?: (f: () => void) => void
extraSources?: Store<unknown>[],
onDestroy?: (f: () => void) => void,
)
public map<J>(
f: (t: T) => J,
onDestroy: (f: () => void) => void,
)
public map<J>(
f: (t: T) => J,
extraSources: Store<unknown>[] | ((f: () => void) => void),
onDestroy?: (f: () => void) => void,
): Store<J> {
return new MappedStore(this, f, extraSources, this._callbacks, f(this.data), onDestroy)
}
@ -946,8 +915,8 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
*/
public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J,
extraSources: Store<unknown>[] = [],
callbackDestroyFunction?: (f: () => void) => void
extraSources?: Store<unknown>[] | ((f: () => void) => void),
callbackDestroyFunction?: (f: () => void) => void,
): Store<J | undefined> {
return new MappedStore(
this,
@ -965,12 +934,12 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
this.data === undefined || this.data === null
? <undefined | null>this.data
: f(<Exclude<T, undefined | null>>this.data),
callbackDestroyFunction
callbackDestroyFunction,
)
}
public mapAsyncD<J>(f: (t: T) => Promise<J>): Store<J> {
return this.bindD((t) => UIEventSource.FromPromise(f(t)))
return this.bindD((t) => UIEventSource.fromPromise(f(t)))
}
/**
@ -985,7 +954,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
f: (t: T) => J,
extraSources: Store<unknown>[],
g: (j: J, t: T) => T,
allowUnregister = false
allowUnregister = false,
): UIEventSource<J> {
const stack = new Error().stack.split("\n")
const callee = stack[1]
@ -1033,4 +1002,16 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
update(f: Updater<T> & ((value: T) => T)): void {
this.setData(f(this.data))
}
public static concat<T>(stores: ReadonlyArray<UIEventSource<T | undefined>>): UIEventSource<(T | undefined)[]> ;
public static concat<T>(stores: ReadonlyArray<UIEventSource<T>>): UIEventSource<T[]> ;
public static concat<T>(stores: ReadonlyArray<UIEventSource<T | undefined>>): UIEventSource<(T | undefined)[]> {
const newStore = <UIEventSource<T[]>>Stores.concat(stores)
newStore.addCallbackD(list => {
for (let i = 0; i < list.length; i++) {
stores[i]?.setData(list[i])
}
})
return newStore
}
}