sumcumo/vue-history

View on GitHub
src/componentHistory.ts

Summary

Maintainability
B
5 hrs
Test Coverage
// import * as serialize is incompatible with compilation to ESM -> UMD
// https://github.com/rollup/rollup-plugin-commonjs/issues/157#issuecomment-284858177
import { getSerialize } from 'json-stringify-safe'
import History from './history'
import {
  Event,
  HistoryInstallOptions,
  VueWithHistory,
} from './types'

const stringify = (val: any) => typeof val === 'string' ? val : JSON.stringify(val, getSerialize(null, undefined))

export default class ComponentHistory extends History {
  private snapshot: string | null = null
  originalMethods: { [key: string]: Function }
  inCallback = 0
  namespace = ''

  constructor(
    options: HistoryInstallOptions,
    private vm: VueWithHistory,
  ) {
    super(options)
    this.originalMethods = Object.assign({}, vm.$options.methods)
    this.namespace = this.vm.$options.name || 'unknown'
  }

  push(event: Event) {
    super.push(event, () => {
      if (this.vm.$globalHistory) {
        this.vm.$globalHistory.push(event)
      }
    })
  }

  created() {
    if (this.options.strict) {
      this.takeSnapshot()
    }
  }

  checkForDataChanges(revealedBy?: any) {
    const newSnapshot = stringify(this.vm.$data)
    if (this.snapshot !== null && newSnapshot !== this.snapshot
      && this.vm.$root.$el // check if only virtual (e.g. mirror inside devTools)
    ) {

      console.error('Changed data asynchronously or outside of sync method', {
        before: JSON.parse(this.snapshot),
        after: JSON.parse(newSnapshot),
        on: this.vm,
        revealedBy,
      })
      this.snapshot = newSnapshot
    }
  }

  takeSnapshot() {
    this.snapshot = stringify(this.vm.$data)
  }

  proxyVM(caller?: Event) {
    const methodKeys = Object.keys(this.vm.$options.methods!)

    return new Proxy(this.vm, {
      get: (target, prop: string, receiver) => {
        if (methodKeys.includes(prop)) {
          return this.proxyMethod(prop, this.originalMethods[prop], caller)
        }
        // @ts-ignore
        const val = Reflect.get(...arguments)
        if (val instanceof Object && val.$history) {
          // track cross-component calls
          return val.$history.proxyVM(caller)
        }
        return val
      },
    })
  }

  trackMethodCall(callData: Partial<Event>): { event: Event, runTracked: (cb: () => any) => any } {
    let setDone: (error?: Error) => void
    const callEvent: Event = {
      timestamp: new Date(),
      namespace: '%none%',
      callId: '%none%',
      caller: undefined,
      payload: '',
      subEvents: [],
      error: null,
      async: false,
      promise: new Promise((resolve, reject) => {
        setDone = function setDone(error?: Error) {
          callEvent.done = new Date()
          if (error) {
            callEvent.error = error
            reject(error)
          } else {
            resolve()
          }
        }
      }),
      done: null,
      ...callData,
    }

    // avoid duplicated logged errors
    callEvent.promise.catch(() => null)

    // defer push until type of event (async) is computed
    const store = () => {
      this.push(callEvent)
      if (callEvent.caller) {
        callEvent.caller.subEvents.push(callEvent)
      }
    }

    function runTracked(cb: () => any) {
      try {
        const res = cb()

        if (res instanceof Promise) {
          callEvent.async = true
          res.then(() => setDone()).catch((e: Error) => setDone(e))
        } else {
          setDone()
        }

        store()
        return res
      } catch (e) {
        setDone(e)
        store()
        throw e
      }
    }

    return { event: callEvent, runTracked }
  }

  proxyMethod(methodKey: string, originalFn: Function, caller?: Event): Function {
    // do not inline to preserve function name for stack-traces
    const proxiedMethod = (...args: any[]) => {
      let namespace = this.namespace
      let callId = methodKey
      let payload = args

      if (methodKey === '$set') {
        namespace += '.$set'
        callId = payload[1]
        payload = payload[2]
      }

      const { event, runTracked } = this.trackMethodCall({
        namespace,
        callId,
        caller,
        payload: stringify(payload),
      })

      const applyToOriginalFunction = () => originalFn.apply(this.proxyVM(event), args)

      if (!this.options.strict || this.inCallback) {
        return runTracked(applyToOriginalFunction)
      }

      this.inCallback += 1

      this.checkForDataChanges({
        type: 'proxyMethod',
        namespace,
        callId,
        payload,
      })
      const res = runTracked(applyToOriginalFunction)
      this.takeSnapshot()

      this.inCallback -= 1
      return res
    }

    return proxiedMethod
  }
}