Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for WeakRefs #3637

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/happy-laws-sell.md
@@ -0,0 +1,5 @@
---
"mobx": minor
---

add support for WeakRef and FinalizationRegistry when using `keepAlive: true` computeds
4 changes: 4 additions & 0 deletions docs/computeds.md
Expand Up @@ -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)
128 changes: 128 additions & 0 deletions 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"])
})
3 changes: 2 additions & 1 deletion packages/mobx/package.json
Expand Up @@ -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",
Expand Down
15 changes: 12 additions & 3 deletions packages/mobx/src/api/autorun.ts
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -59,7 +65,8 @@ export function autorun(
this.track(reactionRunner)
},
opts.onError,
opts.requiresObservable
opts.requiresObservable,
opts.weak
)
} else {
const scheduler = createSchedulerFromOptions(opts)
Expand All @@ -80,7 +87,8 @@ export function autorun(
}
},
opts.onError,
opts.requiresObservable
opts.requiresObservable,
opts.weak
)
}

Expand Down Expand Up @@ -152,7 +160,8 @@ export function reaction<T, FireImmediately extends boolean = false>(
}
},
opts.onError,
opts.requiresObservable
opts.requiresObservable,
opts.weak
)

function reactionRunner() {
Expand Down
25 changes: 23 additions & 2 deletions packages/mobx/src/core/atom.ts
Expand Up @@ -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<IDerivation> {
if (
typeof WeakRef != "undefined" &&
typeof FinalizationRegistry != "undefined" &&
typeof Symbol != "undefined"
) {
const store = new StrongWeakSet<IDerivation>(() => {
if (store.size === 0) {
startBatch()
queueForUnobservation(observee)
endBatch()
}
})
return store
} else {
return new Set<IDerivation>()
}
}

export interface IAtom extends IObservable {
reportObserved(): boolean
reportChanged()
Expand All @@ -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<IDerivation>()
observers_ = createObserverStore(this)

diffValue_ = 0
lastAccessedBy_ = 0
Expand Down
13 changes: 11 additions & 2 deletions packages/mobx/src/core/computedvalue.ts
Expand Up @@ -29,7 +29,8 @@ import {
UPDATE,
die,
allowStateChangesStart,
allowStateChangesEnd
allowStateChangesEnd,
createObserverStore
} from "../internal"

export interface IComputedValue<T> {
Expand All @@ -45,6 +46,12 @@ export interface IComputedValueOptions<T> {
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<T = any> = {
Expand Down Expand Up @@ -81,7 +88,7 @@ export class ComputedValue<T> implements IObservable, IComputedValue<T>, IDeriva
newObserving_ = null // during tracking it's an array with new observed observers
isBeingObserved_ = false
isPendingUnobservation_: boolean = false
observers_ = new Set<IDerivation>()
observers_ = createObserverStore(this)
diffValue_ = 0
runId_ = 0
lastAccessedBy_ = 0
Expand All @@ -99,6 +106,7 @@ export class ComputedValue<T> implements IObservable, IComputedValue<T>, IDeriva
private equals_: IEqualsComparer<any>
private requiresReaction_: boolean | undefined
keepAlive_: boolean
weak_: boolean

/**
* Create a new computed value based on a function expression.
Expand Down Expand Up @@ -132,6 +140,7 @@ export class ComputedValue<T> implements IObservable, IComputedValue<T>, IDeriva
this.scope_ = options.context
this.requiresReaction_ = options.requiresReaction
this.keepAlive_ = !!options.keepAlive
this.weak_ = !!options.weak
}

onBecomeStale_() {
Expand Down
2 changes: 2 additions & 0 deletions packages/mobx/src/core/derivation.ts
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/mobx/src/core/reaction.ts
Expand Up @@ -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_() {
Expand Down
1 change: 1 addition & 0 deletions packages/mobx/src/internal.ts
Expand Up @@ -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"
Expand Down