mobx
Version:
Simple, scalable state management.
277 lines (247 loc) • 9.28 kB
text/typescript
import {
$mobx,
IDerivation,
IDerivationState_,
IObservable,
Lambda,
TraceMode,
clearObserving,
createInstanceofPredicate,
endBatch,
getNextId,
globalState,
isCaughtException,
isSpyEnabled,
shouldCompute,
spyReport,
spyReportEnd,
spyReportStart,
startBatch,
trace,
trackDerivedFunction, GenericAbortSignal
} from "../internal"
/**
* Reactions are a special kind of derivations. Several things distinguishes them from normal reactive computations
*
* 1) They will always run, whether they are used by other computations or not.
* This means that they are very suitable for triggering side effects like logging, updating the DOM and making network requests.
* 2) They are not observable themselves
* 3) They will always run after any 'normal' derivations
* 4) They are allowed to change the state and thereby triggering themselves again, as long as they make sure the state propagates to a stable state in a reasonable amount of iterations.
*
* The state machine of a Reaction is as follows:
*
* 1) after creating, the reaction should be started by calling `runReaction` or by scheduling it (see also `autorun`)
* 2) the `onInvalidate` handler should somehow result in a call to `this.track(someFunction)`
* 3) all observables accessed in `someFunction` will be observed by this reaction.
* 4) as soon as some of the dependencies has changed the Reaction will be rescheduled for another run (after the current mutation or transaction). `isScheduled` will yield true once a dependency is stale and during this period
* 5) `onInvalidate` will be called, and we are back at step 1.
*
*/
export interface IReactionPublic {
dispose(): void
trace(enterBreakPoint?: boolean): void
}
export interface IReactionDisposer {
(): void
$mobx: Reaction
}
export class Reaction implements IDerivation, IReactionPublic {
observing_: IObservable[] = [] // nodes we are looking at. Our value depends on these nodes
newObserving_: IObservable[] = []
dependenciesState_ = IDerivationState_.NOT_TRACKING_
diffValue_ = 0
runId_ = 0
unboundDepsCount_ = 0
isDisposed_ = false
isScheduled_ = false
isTrackPending_ = false
isRunning_ = false
isTracing_: TraceMode = TraceMode.NONE
constructor(
public name_: string = __DEV__ ? "Reaction@" + getNextId() : "Reaction",
private onInvalidate_: () => void,
private errorHandler_?: (error: any, derivation: IDerivation) => void,
public requiresObservable_?
) {}
onBecomeStale_() {
this.schedule_()
}
schedule_() {
if (!this.isScheduled_) {
this.isScheduled_ = true
globalState.pendingReactions.push(this)
runReactions()
}
}
isScheduled() {
return this.isScheduled_
}
/**
* internal, use schedule() if you intend to kick off a reaction
*/
runReaction_() {
if (!this.isDisposed_) {
startBatch()
this.isScheduled_ = false
const prev = globalState.trackingContext
globalState.trackingContext = this
if (shouldCompute(this)) {
this.isTrackPending_ = true
try {
this.onInvalidate_()
if (__DEV__ && this.isTrackPending_ && isSpyEnabled()) {
// onInvalidate didn't trigger track right away..
spyReport({
name: this.name_,
type: "scheduled-reaction"
})
}
} catch (e) {
this.reportExceptionInDerivation_(e)
}
}
globalState.trackingContext = prev
endBatch()
}
}
track(fn: () => void) {
if (this.isDisposed_) {
return
// console.warn("Reaction already disposed") // Note: Not a warning / error in mobx 4 either
}
startBatch()
const notify = isSpyEnabled()
let startTime
if (__DEV__ && notify) {
startTime = Date.now()
spyReportStart({
name: this.name_,
type: "reaction"
})
}
this.isRunning_ = true
const prevReaction = globalState.trackingContext // reactions could create reactions...
globalState.trackingContext = this
const result = trackDerivedFunction(this, fn, undefined)
globalState.trackingContext = prevReaction
this.isRunning_ = false
this.isTrackPending_ = false
if (this.isDisposed_) {
// disposed during last run. Clean up everything that was bound after the dispose call.
clearObserving(this)
}
if (isCaughtException(result)) {
this.reportExceptionInDerivation_(result.cause)
}
if (__DEV__ && notify) {
spyReportEnd({
time: Date.now() - startTime
})
}
endBatch()
}
reportExceptionInDerivation_(error: any) {
if (this.errorHandler_) {
this.errorHandler_(error, this)
return
}
if (globalState.disableErrorBoundaries) {
throw error
}
const message = __DEV__
? `[mobx] Encountered an uncaught exception that was thrown by a reaction or observer component, in: '${this}'`
: `[mobx] uncaught error in '${this}'`
if (!globalState.suppressReactionErrors) {
console.error(message, error)
/** If debugging brought you here, please, read the above message :-). Tnx! */
} else if (__DEV__) { console.warn(`[mobx] (error in reaction '${this.name_}' suppressed, fix error of causing action below)`) } // prettier-ignore
if (__DEV__ && isSpyEnabled()) {
spyReport({
type: "error",
name: this.name_,
message,
error: "" + error
})
}
globalState.globalReactionErrorHandlers.forEach(f => f(error, this))
}
dispose() {
if (!this.isDisposed_) {
this.isDisposed_ = true
if (!this.isRunning_) {
// if disposed while running, clean up later. Maybe not optimal, but rare case
startBatch()
clearObserving(this)
endBatch()
}
}
}
getDisposer_(abortSignal?: GenericAbortSignal): IReactionDisposer {
const dispose = (() => {
this.dispose()
abortSignal?.removeEventListener?.("abort", dispose)
}) as IReactionDisposer
abortSignal?.addEventListener?.("abort", dispose)
dispose[$mobx] = this
return dispose
}
toString() {
return `Reaction[${this.name_}]`
}
trace(enterBreakPoint: boolean = false) {
trace(this, enterBreakPoint)
}
}
export function onReactionError(handler: (error: any, derivation: IDerivation) => void): Lambda {
globalState.globalReactionErrorHandlers.push(handler)
return () => {
const idx = globalState.globalReactionErrorHandlers.indexOf(handler)
if (idx >= 0) {
globalState.globalReactionErrorHandlers.splice(idx, 1)
}
}
}
/**
* Magic number alert!
* Defines within how many times a reaction is allowed to re-trigger itself
* until it is assumed that this is gonna be a never ending loop...
*/
const MAX_REACTION_ITERATIONS = 100
let reactionScheduler: (fn: () => void) => void = f => f()
export function runReactions() {
// Trampolining, if runReactions are already running, new reactions will be picked up
if (globalState.inBatch > 0 || globalState.isRunningReactions) {
return
}
reactionScheduler(runReactionsHelper)
}
function runReactionsHelper() {
globalState.isRunningReactions = true
const allReactions = globalState.pendingReactions
let iterations = 0
// While running reactions, new reactions might be triggered.
// Hence we work with two variables and check whether
// we converge to no remaining reactions after a while.
while (allReactions.length > 0) {
if (++iterations === MAX_REACTION_ITERATIONS) {
console.error(
__DEV__
? `Reaction doesn't converge to a stable state after ${MAX_REACTION_ITERATIONS} iterations.` +
` Probably there is a cycle in the reactive function: ${allReactions[0]}`
: `[mobx] cycle in reaction: ${allReactions[0]}`
)
allReactions.splice(0) // clear reactions
}
let remainingReactions = allReactions.splice(0)
for (let i = 0, l = remainingReactions.length; i < l; i++) {
remainingReactions[i].runReaction_()
}
}
globalState.isRunningReactions = false
}
export const isReaction = createInstanceofPredicate("Reaction", Reaction)
export function setReactionScheduler(fn: (f: () => void) => void) {
const baseScheduler = reactionScheduler
reactionScheduler = f => fn(() => baseScheduler(f))
}