From c20a32c73f5487fa53c3070cee16a8936781e78a Mon Sep 17 00:00:00 2001 From: Ethan Knapp Date: Wed, 22 Feb 2023 23:10:54 -0700 Subject: [PATCH] add support for WeakRefs --- .changeset/happy-laws-sell.md | 5 + docs/computeds.md | 4 + packages/mobx/__tests__/v5/base/weakset.ts | 128 +++++++++++++++++++++ packages/mobx/package.json | 3 +- packages/mobx/src/api/autorun.ts | 15 ++- packages/mobx/src/core/atom.ts | 25 +++- packages/mobx/src/core/computedvalue.ts | 13 ++- packages/mobx/src/core/derivation.ts | 2 + packages/mobx/src/core/reaction.ts | 3 +- packages/mobx/src/internal.ts | 1 + packages/mobx/src/utils/weakset.ts | 121 +++++++++++++++++++ packages/mobx/tsconfig.json | 3 +- tsconfig.test.json | 3 +- 13 files changed, 315 insertions(+), 11 deletions(-) create mode 100644 .changeset/happy-laws-sell.md create mode 100644 packages/mobx/__tests__/v5/base/weakset.ts create mode 100644 packages/mobx/src/utils/weakset.ts diff --git a/.changeset/happy-laws-sell.md b/.changeset/happy-laws-sell.md new file mode 100644 index 000000000..7dda2c466 --- /dev/null +++ b/.changeset/happy-laws-sell.md @@ -0,0 +1,5 @@ +--- +"mobx": minor +--- + +add support for WeakRef and FinalizationRegistry when using `keepAlive: true` computeds diff --git a/docs/computeds.md b/docs/computeds.md index 45befc1ad..2c07a640a 100644 --- a/docs/computeds.md +++ b/docs/computeds.md @@ -236,3 +236,7 @@ It is recommended to set this one to `true` on very expensive computed values. I ### `keepAlive` This avoids suspending computed values when they are not being observed by anything (see the above explanation). Can potentially create memory leaks, similar to the ones discussed for [reactions](reactions.md#always-dispose-of-reactions). + +### `weak` + +Intended for use with `keepAlive`. When `true`, MobX will use a `WeakRef` (_if_ you're not targeting something old that doesn't support `WeakRef`s) when add the `computed` to any `observables`. If your reference to the `computed` is garbage collected, the `computed` will be too (instead of `observable`s holding references and preventing garbage collection) diff --git a/packages/mobx/__tests__/v5/base/weakset.ts b/packages/mobx/__tests__/v5/base/weakset.ts new file mode 100644 index 000000000..934e15b2e --- /dev/null +++ b/packages/mobx/__tests__/v5/base/weakset.ts @@ -0,0 +1,128 @@ +import { + IObservableValue, + autorun, + computed, + observable, + onBecomeObserved, + onBecomeUnobserved, + runInAction +} from "../../../src/mobx" +const gc = require("expose-gc/function") + +let events: string[] = [] +beforeEach(() => { + events = [] +}) + +function nextFrame() { + return new Promise(accept => setTimeout(accept, 1)) +} + +async function gc_cycle() { + await nextFrame() + gc() + await nextFrame() +} + +test("observables should not hold a reference to weak reactions", async () => { + let x = 0 + const o = observable.box(10) + + ;(() => { + const au = autorun( + () => { + x += o.get() + }, + { weak: true } + ) + + o.set(5) + expect(x).toEqual(15) + })() + + await gc_cycle() + expect((o as any).observers_.size).toEqual(0) + + o.set(20) + expect(x).toEqual(15) +}) + +test("observables should hold a reference to reactions", async () => { + let x = 0 + const o = observable.box(10) + ;(() => { + autorun(() => { + x += o.get() + }, {}) + + o.set(5) + })() + + await gc_cycle() + expect((o as any).observers_.size).toEqual(1) + + o.set(20) + expect(x).toEqual(35) +}) + +test("observables should not hold a reference to weak computeds", async () => { + const o = observable.box(10) + let wref + ;(() => { + const kac = computed( + () => { + return o.get() + }, + { keepAlive: true, weak: true } + ) + wref = new WeakRef(kac) + kac.get() + })() + + expect(wref.deref()).not.toEqual(null) + await gc_cycle() + expect(wref.deref() == null).toBeTruthy() + expect((o as any).observers_.size).toEqual(0) +}) + +test("observables should hold a reference to computeds", async () => { + const o = observable.box(10) + let wref + ;(() => { + const kac = computed( + () => { + return o.get() + }, + { keepAlive: true } + ) + kac.get() + wref = new WeakRef(kac) + })() + + expect(wref.deref() != null).toBeTruthy() + await nextFrame() + gc() + await nextFrame() + expect(wref.deref() != null).toBeTruthy() + expect((o as any).observers_.size).toEqual(1) +}) + +test("garbage collection should trigger onBOU", async () => { + const o = observable.box(10) + + onBecomeObserved(o, () => events.push(`o observed`)) + onBecomeUnobserved(o, () => events.push(`o unobserved`)) + + ;(() => { + autorun( + () => { + o.get() + }, + { weak: true } + ) + })() + + expect(events).toEqual(["o observed"]) + await gc_cycle() + expect(events).toEqual(["o observed", "o unobserved"]) +}) diff --git a/packages/mobx/package.json b/packages/mobx/package.json index 3df884d14..1f28876f3 100644 --- a/packages/mobx/package.json +++ b/packages/mobx/package.json @@ -44,7 +44,8 @@ "@babel/preset-typescript": "^7.9.0", "@babel/runtime": "^7.9.2", "conditional-type-checks": "^1.0.5", - "flow-bin": "^0.123.0" + "flow-bin": "^0.123.0", + "expose-gc": "^1.0.0" }, "keywords": [ "mobx", diff --git a/packages/mobx/src/api/autorun.ts b/packages/mobx/src/api/autorun.ts index 1ddc23e4b..42ea82555 100644 --- a/packages/mobx/src/api/autorun.ts +++ b/packages/mobx/src/api/autorun.ts @@ -25,6 +25,12 @@ export interface IAutorunOptions { requiresObservable?: boolean scheduler?: (callback: () => void) => any onError?: (error: any) => void + /** + * Observees will not prevent this Reaction from being garbage collected and disposed - you'll need to keep a reference to it somewhere + * + * This is an advanced feature that, in 99.99% of cases you won't need. + */ + weak?: boolean } /** @@ -59,7 +65,8 @@ export function autorun( this.track(reactionRunner) }, opts.onError, - opts.requiresObservable + opts.requiresObservable, + opts.weak ) } else { const scheduler = createSchedulerFromOptions(opts) @@ -80,7 +87,8 @@ export function autorun( } }, opts.onError, - opts.requiresObservable + opts.requiresObservable, + opts.weak ) } @@ -152,7 +160,8 @@ export function reaction( } }, opts.onError, - opts.requiresObservable + opts.requiresObservable, + opts.weak ) function reactionRunner() { diff --git a/packages/mobx/src/core/atom.ts b/packages/mobx/src/core/atom.ts index c62cbd703..ff7529175 100644 --- a/packages/mobx/src/core/atom.ts +++ b/packages/mobx/src/core/atom.ts @@ -11,11 +11,32 @@ import { propagateChanged, reportObserved, startBatch, - Lambda + Lambda, + StrongWeakSet, + queueForUnobservation } from "../internal" export const $mobx = Symbol("mobx administration") +export function createObserverStore(observee: IObservable): Set { + if ( + typeof WeakRef != "undefined" && + typeof FinalizationRegistry != "undefined" && + typeof Symbol != "undefined" + ) { + const store = new StrongWeakSet(() => { + if (store.size === 0) { + startBatch() + queueForUnobservation(observee) + endBatch() + } + }) + return store + } else { + return new Set() + } +} + export interface IAtom extends IObservable { reportObserved(): boolean reportChanged() @@ -24,7 +45,7 @@ export interface IAtom extends IObservable { export class Atom implements IAtom { isPendingUnobservation_ = false // for effective unobserving. BaseAtom has true, for extra optimization, so its onBecomeUnobserved never gets called, because it's not needed isBeingObserved_ = false - observers_ = new Set() + observers_ = createObserverStore(this) diffValue_ = 0 lastAccessedBy_ = 0 diff --git a/packages/mobx/src/core/computedvalue.ts b/packages/mobx/src/core/computedvalue.ts index a56d59525..b4e754bc5 100644 --- a/packages/mobx/src/core/computedvalue.ts +++ b/packages/mobx/src/core/computedvalue.ts @@ -29,7 +29,8 @@ import { UPDATE, die, allowStateChangesStart, - allowStateChangesEnd + allowStateChangesEnd, + createObserverStore } from "../internal" export interface IComputedValue { @@ -45,6 +46,12 @@ export interface IComputedValueOptions { context?: any requiresReaction?: boolean keepAlive?: boolean + /** + * Stop any observees from preventing this computed from being garbage collected + * + * This is an advanced feature and primarily intended for use with `keepAlive` computeds. + */ + weak?: boolean } export type IComputedDidChange = { @@ -81,7 +88,7 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva newObserving_ = null // during tracking it's an array with new observed observers isBeingObserved_ = false isPendingUnobservation_: boolean = false - observers_ = new Set() + observers_ = createObserverStore(this) diffValue_ = 0 runId_ = 0 lastAccessedBy_ = 0 @@ -99,6 +106,7 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva private equals_: IEqualsComparer private requiresReaction_: boolean | undefined keepAlive_: boolean + weak_: boolean /** * Create a new computed value based on a function expression. @@ -132,6 +140,7 @@ export class ComputedValue implements IObservable, IComputedValue, IDeriva this.scope_ = options.context this.requiresReaction_ = options.requiresReaction this.keepAlive_ = !!options.keepAlive + this.weak_ = !!options.weak } onBecomeStale_() { diff --git a/packages/mobx/src/core/derivation.ts b/packages/mobx/src/core/derivation.ts index 70ee16959..69e71f7f9 100644 --- a/packages/mobx/src/core/derivation.ts +++ b/packages/mobx/src/core/derivation.ts @@ -58,6 +58,8 @@ export interface IDerivation extends IDepTreeNode { * warn if the derivation has no dependencies after creation/update */ requiresObservable_?: boolean + + readonly weak_: boolean } export class CaughtException { diff --git a/packages/mobx/src/core/reaction.ts b/packages/mobx/src/core/reaction.ts index dadfdcb13..0cd9a0ec6 100644 --- a/packages/mobx/src/core/reaction.ts +++ b/packages/mobx/src/core/reaction.ts @@ -67,7 +67,8 @@ export class Reaction implements IDerivation, IReactionPublic { public name_: string = __DEV__ ? "Reaction@" + getNextId() : "Reaction", private onInvalidate_: () => void, private errorHandler_?: (error: any, derivation: IDerivation) => void, - public requiresObservable_? + public requiresObservable_?, + readonly weak_ = false ) {} onBecomeStale_() { diff --git a/packages/mobx/src/internal.ts b/packages/mobx/src/internal.ts index c476084fd..d56c80b22 100644 --- a/packages/mobx/src/internal.ts +++ b/packages/mobx/src/internal.ts @@ -8,6 +8,7 @@ but at least in this file we can magically reorder the imports with trial and er export * from "./utils/global" export * from "./errors" export * from "./utils/utils" +export * from "./utils/weakset" export * from "./api/decorators" export * from "./core/atom" export * from "./utils/comparer" diff --git a/packages/mobx/src/utils/weakset.ts b/packages/mobx/src/utils/weakset.ts new file mode 100644 index 000000000..f4dc7f9b1 --- /dev/null +++ b/packages/mobx/src/utils/weakset.ts @@ -0,0 +1,121 @@ +interface WeakMapEntry { + ref: WeakRef + token: any +} + +export class WeakRefSet implements Set { + constructor(readonly onItemCleaned?: () => void) { + this._registry = new FinalizationRegistry>(key => { + this._set.delete(key) + this.onItemCleaned?.() + }) + } + + private _set = new Set>() + private _map = new WeakMap>() + private _registry: FinalizationRegistry> + + get size() { + return this._size() + } + [Symbol.toStringTag]: string = "[Object object]" + + protected _size() { + return this._set.size + } + + add(value: T): this { + const entry: WeakMapEntry = { + ref: new WeakRef(value), + token: {} + } + this._set.add(entry.ref) + this._map.set(value, entry) + this._registry.register(value, entry.ref, entry.token) + return this + } + + clear(): void { + this._set.clear() + this._map = new WeakMap() + } + + delete(value: T): boolean { + const entry = this._map.get(value) + if (!entry) { + return false + } + this._map.delete(value) + this._registry.unregister(entry.token) + return this._set.delete(entry.ref) + } + + forEach(callbackfn: (value: T, value2: T, set: Set) => void, thisArg?: any): void { + for (let v of this.values()) { + callbackfn.call(thisArg, v, v, this) + } + } + + has(value: T): boolean { + return this._map.has(value) + } + + *values(): IterableIterator { + for (let v of this._set) { + const ref = v.deref() + if (ref == undefined) { + continue + } + yield ref + } + } + + *entries(): IterableIterator<[T, T]> { + for (let ref of this) { + yield [ref, ref] + } + } + + *keys(): IterableIterator { + yield* this + } + + [Symbol.iterator]() { + return this.values() + } +} + +export class StrongWeakSet extends WeakRefSet { + private _strong = new Set() + + protected _size() { + return super._size() + this._strong.size + } + + has(value: T): boolean { + return value.weak_ ? super.has(value) : this._strong.has(value) + } + + add(value: T): this { + if (value.weak_) { + super.add(value) + } else { + this._strong.add(value) + } + return this + } + + clear(): void { + super.clear() + this._strong.clear() + } + + delete(value: T): boolean { + return value.weak_ ? super.delete(value) : this._strong.delete(value) + } + + *values(): IterableIterator { + yield* this._strong.values() + yield* super.values() + } +} diff --git a/packages/mobx/tsconfig.json b/packages/mobx/tsconfig.json index 1e0a96e76..86ca5b36e 100644 --- a/packages/mobx/tsconfig.json +++ b/packages/mobx/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "rootDir": "src" + "rootDir": "src", + "lib": ["ES2021.WeakRef"] }, "include": ["src"] } diff --git a/tsconfig.test.json b/tsconfig.test.json index f8e4f8c91..53de6cbbb 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -3,6 +3,7 @@ "compilerOptions": { // "module": "commonjs", "allowJs": true, - "noUnusedLocals": false + "noUnusedLocals": false, + "lib": ["ES2021.WeakRef"] } }