UNPKG

mobx-keystone

Version:

A MobX powered state management solution based on data trees with first class support for TypeScript, snapshots, patches and much more

434 lines (385 loc) 12.1 kB
import { ActionContext, ActionContextActionType, ActionContextAsyncStepType, } from "../action/context" import { ActionMiddleware, ActionMiddlewareDisposer, addActionMiddleware, } from "../action/middleware" import type { FlowFinisher } from "../action/modelFlow" import type { AnyModel } from "../model/BaseModel" import { assertTweakedObject } from "../tweaker/core" import { failure } from "../utils" /** * Simplified version of action context. */ export interface SimpleActionContext { /** * Action name */ readonly actionName: string /** * Action type, sync or async. */ readonly type: ActionContextActionType /** * Action target model instance. */ readonly target: AnyModel /** * Array of action arguments. */ readonly args: ReadonlyArray<any> /** * Parent action context, if any. */ readonly parentContext?: SimpleActionContext /** * Root action context, or itself if the root. */ readonly rootContext: SimpleActionContext /** * Custom data for the action context to be set by middlewares, an object. * Symbols must be used as keys to avoid name clashing between middlewares. */ readonly data: Record<symbol, any> } /** * Action tracking middleware finish result. */ export enum ActionTrackingResult { /** * The action returned normally (without throwing). */ Return = "return", /** * The action threw an error. */ Throw = "throw", } /** * Action tracking middleware hooks. */ export interface ActionTrackingMiddleware { /** * Filter function called whenever each action starts, and only then. * * If the action is accepted then `onStart`, `onResume`, `onSuspend` and `onFinish` * for that particular action will be called. * * All actions are accepted by default if no filter function is present. * * @param ctx Simplified action context. * @returns true to accept the action, false to skip it. */ filter?(ctx: SimpleActionContext): boolean /** * Called when an action just started. * * @param ctx Simplified action context. * @returns Can optionally return a result that will cancel the original action and finish it * with the returned value / error to be thrown. In either case case resume / suspend / finish will * still be called normally. */ onStart?(ctx: SimpleActionContext): void | ActionTrackingReturn /** * Called when an action just resumed a synchronous piece of code execution. * Gets called once for sync actions and multiple times for flows. * * @param ctx Simplified action context. */ onResume?(ctx: SimpleActionContext): void /** * Called when an action just finished a synchronous pice of code execution. * Note that this doesn't necessarily mean the action is finished. * Gets called once for sync actions and multiple times for flows. * * @param ctx Simplified action context. */ onSuspend?(ctx: SimpleActionContext): void /** * Called when an action just finished, either by returning normally or by throwing an error. * * @param ctx Simplified action context. * @param ret Action return result. * @returns Can optionally return a new result that will override the original one. */ onFinish?(ctx: SimpleActionContext, ret: ActionTrackingReturn): void | ActionTrackingReturn } /** * Return result of an action. */ export interface ActionTrackingReturn { result: ActionTrackingResult value: any } /** * Creates an action tracking middleware, which is a simplified version * of the standard action middleware. * * @param subtreeRoot Subtree root target object. * @param hooks Middleware hooks. * @returns The middleware disposer. */ export function actionTrackingMiddleware( subtreeRoot: object, hooks: ActionTrackingMiddleware ): ActionMiddlewareDisposer { assertTweakedObject(subtreeRoot, "subtreeRoot") const dataSymbol = Symbol("actionTrackingMiddlewareData") enum State { Idle = "idle", Started = "started", RealResumed = "realResumed", FakeResumed = "fakeResumed", Suspended = "suspended", Finished = "finished", } interface Data { startAccepted: boolean state: State } function getCtxData(ctx: ActionContext | SimpleActionContext): Data | undefined { return ctx.data[dataSymbol] } function setCtxData(ctx: ActionContext | SimpleActionContext, partialData: Partial<Data>) { const currentData = ctx.data[dataSymbol] if (currentData) { Object.assign(currentData, partialData) } else { ctx.data[dataSymbol] = partialData } } const userFilter: ActionMiddleware["filter"] = (ctx) => { if (hooks.filter) { return hooks.filter(simplifyActionContext(ctx)) } return true } const resumeSuspendSupport = !!hooks.onResume || !!hooks.onSuspend const filter: ActionMiddleware["filter"] = (ctx) => { if (ctx.type === ActionContextActionType.Sync) { // start and finish is on the same context const accepted = userFilter(ctx) if (accepted) { setCtxData(ctx, { startAccepted: true, state: State.Idle, }) } return accepted } else { switch (ctx.asyncStepType) { case ActionContextAsyncStepType.Spawn: { const accepted = userFilter(ctx) if (accepted) { setCtxData(ctx, { startAccepted: true, state: State.Idle, }) } return accepted } case ActionContextAsyncStepType.Return: case ActionContextAsyncStepType.Throw: { // depends if the spawn one was accepted or not const data = getCtxData(ctx.spawnAsyncStepContext!) return data ? data.startAccepted : false } case ActionContextAsyncStepType.Resume: case ActionContextAsyncStepType.ResumeError: if (resumeSuspendSupport) { // depends if the spawn one was accepted or not const data = getCtxData(ctx.spawnAsyncStepContext!) return data ? data.startAccepted : false } else { return false } default: return false } } } const start = (simpleCtx: SimpleActionContext): ActionTrackingReturn | undefined => { setCtxData(simpleCtx, { state: State.Started, }) if (hooks.onStart) { return hooks.onStart(simpleCtx) || undefined } return undefined } const finish = ( simpleCtx: SimpleActionContext, ret: ActionTrackingReturn ): ActionTrackingReturn => { // fakely resume and suspend the parent if needed const parentCtx = simpleCtx.parentContext let parentResumed = false if (parentCtx) { const parentData = getCtxData(parentCtx) if (parentData?.startAccepted && parentData.state === State.Suspended) { parentResumed = true resume(parentCtx, false) } } setCtxData(simpleCtx, { state: State.Finished, }) if (hooks.onFinish) { ret = hooks.onFinish(simpleCtx, ret) || ret } if (parentResumed) { suspend(parentCtx!) } return ret } const resume = (simpleCtx: SimpleActionContext, real: boolean) => { // ensure parents are resumed const parentCtx = simpleCtx.parentContext if (parentCtx) { const parentData = getCtxData(parentCtx) if (parentData?.startAccepted && parentData.state === State.Suspended) { resume(parentCtx, false) } } setCtxData(simpleCtx, { state: real ? State.RealResumed : State.FakeResumed, }) if (hooks.onResume) { hooks.onResume(simpleCtx) } } const suspend = (simpleCtx: SimpleActionContext) => { setCtxData(simpleCtx, { state: State.Suspended, }) if (hooks.onSuspend) { hooks.onSuspend(simpleCtx) } // ensure parents are suspended if they were fakely resumed const parentCtx = simpleCtx.parentContext if (parentCtx) { const parentData = getCtxData(parentCtx) if (parentData?.startAccepted && parentData.state === State.FakeResumed) { suspend(parentCtx) } } } const mware: ActionMiddleware["middleware"] = (ctx, next) => { const simpleCtx = simplifyActionContext(ctx) const origNext = next next = () => { resume(simpleCtx, true) try { return origNext() } finally { suspend(simpleCtx) } } if (ctx.type === ActionContextActionType.Sync) { let retObj = start(simpleCtx) if (retObj) { // action canceled / overridden by onStart resume(simpleCtx, true) suspend(simpleCtx) retObj = finish(simpleCtx, retObj) } else { try { retObj = finish(simpleCtx, { result: ActionTrackingResult.Return, value: next() }) } catch (err) { retObj = finish(simpleCtx, { result: ActionTrackingResult.Throw, value: err }) } } return returnOrThrowActionTrackingReturn(retObj) } else { // async switch (ctx.asyncStepType) { case ActionContextAsyncStepType.Spawn: { let retObj = start(simpleCtx) if (retObj) { // action canceled / overridden by onStart resume(simpleCtx, true) suspend(simpleCtx) retObj = finish(simpleCtx, retObj) return returnOrThrowActionTrackingReturn(retObj) } else { return next() } } case ActionContextAsyncStepType.Return: { const flowFinisher: FlowFinisher = next() const retObj = finish(simpleCtx, { result: ActionTrackingResult.Return, value: flowFinisher.value, }) flowFinisher.resolution = retObj.result === ActionTrackingResult.Return ? "accept" : "reject" flowFinisher.value = retObj.value return flowFinisher } case ActionContextAsyncStepType.Throw: { const flowFinisher: FlowFinisher = next() const retObj = finish(simpleCtx, { result: ActionTrackingResult.Throw, value: flowFinisher.value, }) flowFinisher.resolution = retObj.result === ActionTrackingResult.Return ? "accept" : "reject" flowFinisher.value = retObj.value return flowFinisher } case ActionContextAsyncStepType.Resume: case ActionContextAsyncStepType.ResumeError: if (resumeSuspendSupport) { return next() } else { throw failure( `assertion error: async step should have been filtered out - ${ctx.asyncStepType}` ) } default: throw failure( `assertion error: async step should have been filtered out - ${ctx.asyncStepType}` ) } } } return addActionMiddleware({ middleware: mware, filter, subtreeRoot: subtreeRoot }) } function returnOrThrowActionTrackingReturn(retObj: ActionTrackingReturn) { if (retObj.result === ActionTrackingResult.Return) { return retObj.value } else { throw retObj.value } } const simpleDataContextSymbol = Symbol("simpleDataContext") /** * Simplifies an action context by converting an async call hierarchy into a simpler one. * * @param ctx Action context to convert. * @returns Simplified action context. */ export function simplifyActionContext(ctx: ActionContext): SimpleActionContext { while (ctx.previousAsyncStepContext) { ctx = ctx.previousAsyncStepContext } let simpleCtx = ctx.data[simpleDataContextSymbol] if (!simpleCtx) { const parentContext = ctx.parentContext ? simplifyActionContext(ctx.parentContext) : undefined simpleCtx = { actionName: ctx.actionName, type: ctx.type, target: ctx.target, args: ctx.args, data: ctx.data, parentContext, } simpleCtx.rootContext = parentContext ? parentContext.rootContext : simpleCtx ctx.data[simpleDataContextSymbol] = simpleCtx } return simpleCtx }