Skip to content

feat(devtools): state diffing #1585

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

Draft
wants to merge 6 commits into
base: v2
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 275 additions & 0 deletions packages/pinia/__tests__/devtools/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { describe, it, expect } from 'vitest'
import { formatStateDifferences, realTypeOf } from '../../src/devtools/utils'

describe('Devtools utils', () => {
describe('realTypeOf', () => {
it('Should correctly predict type of subject', () => {
const number = 0
const string = 'undefined'
const undefinedValue = undefined
const nullValue = null
const array: any[] = []
const date = new Date(123)
const object = {}
const regexp = /regexp/
const functionValue = () => {}

let type = realTypeOf(number)

expect(type).toEqual('number')

type = realTypeOf(string)

expect(type).toEqual('string')

type = realTypeOf(undefinedValue)

expect(type).toEqual('undefined')

type = realTypeOf(nullValue)

expect(type).toEqual('null')

type = realTypeOf(array)

expect(type).toEqual('array')

type = realTypeOf(date)

expect(type).toEqual('date')

type = realTypeOf(object)

expect(type).toEqual('object')

type = realTypeOf(regexp)

expect(type).toEqual('regexp')

type = realTypeOf(functionValue)

expect(type).toEqual('function')
})
})

describe('formatStateDifferences', () => {
it('Should find removed entries', () => {
const oldState = {
removed: 'old',
}
const newState = {}

const differences = formatStateDifferences(oldState, newState)

expect(differences).toEqual({
removed: undefined,
})
})

it('Should find difference in array', () => {
const oldState = {
changedArray1: [1, 2, 3],
unchangedArray: [1, 2, 3],
changedArray2: [1, 2, 3],
}
const newState = {
changedArray1: [1, 2, 3, 4],
unchangedArray: [1, 2, 3],
changedArray2: [3, 2, 1],
}

const differences = formatStateDifferences(oldState, newState)

expect(differences).toEqual({
changedArray1: [1, 2, 3, 4],
changedArray2: [3, 2, 1],
})
})

it('Should find difference in regexp', () => {
const oldState = {
changedRegexp: /changed/,
unchangedRegexp: /unchanged/,
}
const newState = {
changedRegexp: /changedToNewValue/,
unchangedRegexp: /unchanged/,
}

const differences = formatStateDifferences(oldState, newState)

expect(differences).toEqual({
changedRegexp: /changedToNewValue/,
})
})

it('Should find difference in date', () => {
const oldState = {
changedDate: new Date(123),
unchangedDate: new Date(123),
}
const newState = {
changedDate: new Date(1234),
unchangedDate: new Date(123),
}

const differences = formatStateDifferences(oldState, newState)

expect(differences).toEqual({
changedDate: new Date(1234),
})
})

it('Should find difference in booleans', () => {
const oldState = {
changedBool: true,
unchangedBool: true,
}
const newState = {
changedBool: false,
unchangedBool: true,
}

const differences = formatStateDifferences(oldState, newState)

expect(differences).toEqual({
changedBool: false,
})
})

it('Should find difference in numbers', () => {
const oldState = {
changedNumber: 10,
unchangedNumber: 10,
}
const newState = {
changedNumber: 9,
unchangedNumber: 10,
}

const differences = formatStateDifferences(oldState, newState)

expect(differences).toEqual({
changedNumber: 9,
})
})

it('Should find difference in strings', () => {
const oldState = {
changedString: 'changed',
unchangedString: 'unchanged',
}
const newState = {
changedString: 'changedToNewValue',
unchangedString: 'unchanged',
}

const differences = formatStateDifferences(oldState, newState)

expect(differences).toEqual({
changedString: 'changedToNewValue',
})
})

it('Should find new values', () => {
const oldState = {}
const newState = {
newValue: 10,
}

const differences = formatStateDifferences(oldState, newState)

expect(differences).toEqual({
newValue: 10,
})
})

it('Should correctly see changes deep in objects', () => {
const oldState = {
changedObject: {
key1: 'unchanged',
key2: {
key1: {
key1: {
key1: false,
key2: true,
},
},
},
key3: {
key1: {
key1: {},
},
key2: {
key1: 'abc',
},
},
key4: 50,
},
}
const newState = {
changedObject: {
key1: 'unchanged',
key2: {
key1: {
key1: {
key1: true,
key2: true,
},
},
},
key3: {
key1: {
key1: {},
},
key2: {
key1: 'abcd',
},
},
key4: 50,
},
}

const differences = formatStateDifferences(oldState, newState)

expect(differences).toEqual({
changedObject: {
key2: {
key1: {
key1: {
key1: true,
},
},
},
key3: {
key2: {
key1: 'abcd',
},
},
},
})
})

it('Should find the difference between functions', () => {
const foo = () => {}
const bar = () => {}
const foobar = () => {}

const oldState = {
foo,
bar,
}

const newState = {
foo: foobar,
bar,
}

const differences = formatStateDifferences(oldState, newState)

expect(differences).toEqual({
foo: foobar,
})
})
})
})
408 changes: 408 additions & 0 deletions packages/pinia/src/devtools/fastCopy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,408 @@
// Clone deep utility for cloning state of the store
// Forked from https://github.com/planttheidea/fast-copy
// Last update: 24-08-2022

declare namespace FastCopy {
export type Realm = Record<string, any>

export interface Cache {
_keys?: any[]
_values?: any[]
has: (value: any) => boolean
set: (key: any, value: any) => void
get: (key: any) => any
}

export type Copier = <Value = any>(value: Value, cache: Cache) => Value

export type ObjectCloner = <Value>(
object: Value,
realm: Realm,
handleCopy: Copier,
cache: Cache
) => Value

export type Options = {
isStrict?: boolean
realm?: Realm
}
}

const { toString: toStringFunction } = Function.prototype
const {
create,
defineProperty,
getOwnPropertyDescriptor,
getOwnPropertyNames,
getOwnPropertySymbols,
getPrototypeOf,
} = Object

const SYMBOL_PROPERTIES = typeof getOwnPropertySymbols === 'function'
const WEAK_MAP = typeof WeakMap === 'function'

/**
* @function createCache
*
* @description
* get a new cache object to prevent circular references
*
* @returns the new cache object
*/
export const createCache = (() => {
if (WEAK_MAP) {
return (): FastCopy.Cache => new WeakMap()
}

class Cache {
_keys: any[] = []
_values: any[] = []

has(key: any) {
return !!~this._keys.indexOf(key)
}

get(key: any) {
return this._values[this._keys.indexOf(key)]
}

set(key: any, value: any) {
this._keys.push(key)
this._values.push(value)
}
}

return (): FastCopy.Cache => new Cache()
})()

/**
* @function getCleanClone
*
* @description
* get an empty version of the object with the same prototype it has
*
* @param object the object to build a clean clone from
* @param realm the realm the object resides in
* @returns the empty cloned object
*/
export const getCleanClone = (object: any, realm: FastCopy.Realm): any => {
const prototype = object.__proto__ || getPrototypeOf(object)

if (!prototype) {
return create(null)
}

const Constructor = prototype.constructor

if (Constructor === realm.Object) {
return prototype === realm.Object.prototype ? {} : create(prototype)
}

if (~toStringFunction.call(Constructor).indexOf('[native code]')) {
try {
return new Constructor()
} catch {}
}

return create(prototype)
}

/**
* @function getObjectCloneStrict
*
* @description
* get a copy of the object based on strict rules, meaning all keys and symbols
* are copied based on the original property descriptors
*
* @param object the object to clone
* @param realm the realm the object resides in
* @param handleCopy the function that handles copying the object
* @returns the copied object
*/
export const getObjectCloneStrict: FastCopy.ObjectCloner = (
object: any,
realm: FastCopy.Realm,
handleCopy: FastCopy.Copier,
cache: FastCopy.Cache
): any => {
const clone: any = getCleanClone(object, realm)

// set in the cache immediately to be able to reuse the object recursively
cache.set(object, clone)

const properties: (string | symbol)[] = SYMBOL_PROPERTIES
? getOwnPropertyNames(object).concat(
getOwnPropertySymbols(object) as unknown as string[]
)
: getOwnPropertyNames(object)

for (
let index = 0, length = properties.length, property, descriptor;
index < length;
++index
) {
property = properties[index]

if (property !== 'callee' && property !== 'caller') {
descriptor = getOwnPropertyDescriptor(object, property)

if (descriptor) {
// Only clone the value if actually a value, not a getter / setter.
if (!descriptor.get && !descriptor.set) {
descriptor.value = handleCopy(object[property], cache)
}

try {
defineProperty(clone, property, descriptor)
} catch (error) {
// Tee above can fail on node in edge cases, so fall back to the loose assignment.
clone[property] = descriptor.value
}
} else {
// In extra edge cases where the property descriptor cannot be retrived, fall back to
// the loose assignment.
clone[property] = handleCopy(object[property], cache)
}
}
}

return clone
}

/**
* @function getRegExpFlags
*
* @description
* get the flags to apply to the copied regexp
*
* @param regExp the regexp to get the flags of
* @returns the flags for the regexp
*/
export const getRegExpFlags = (regExp: RegExp): string => {
let flags = ''

if (regExp.global) {
flags += 'g'
}

if (regExp.ignoreCase) {
flags += 'i'
}

if (regExp.multiline) {
flags += 'm'
}

if (regExp.unicode) {
flags += 'u'
}

if (regExp.sticky) {
flags += 'y'
}

return flags
}

const { isArray } = Array

const GLOBAL_THIS: FastCopy.Realm = (function () {
if (typeof globalThis !== 'undefined') {
return globalThis
}

if (typeof self !== 'undefined') {
return self
}

if (typeof window !== 'undefined') {
return window
}

if (typeof global !== 'undefined') {
return global
}

if (console && console.error) {
console.error('Unable to locate global object, returning "this".')
}

// @ts-ignore
return this
})()

/**
* @function copy
*
* @description
* copy an value deeply as much as possible
*
* If `strict` is applied, then all properties (including non-enumerable ones)
* are copied with their original property descriptors on both objects and arrays.
*
* The value is compared to the global constructors in the `realm` provided,
* and the native constructor is always used to ensure that extensions of native
* objects (allows in ES2015+) are maintained.
*
* @param value the value to copy
* @param [options] the options for copying with
* @param [options.isStrict] should the copy be strict
* @param [options.realm] the realm (this) value the value is copied from
* @returns the copied value
*/
function copy<Value>(value: Value, options?: FastCopy.Options): Value {
// manually coalesced instead of default parameters for performance
const realm = (options && options.realm) || GLOBAL_THIS
const getObjectClone = getObjectCloneStrict

/**
* @function handleCopy
*
* @description
* copy the value recursively based on its type
*
* @param value the value to copy
* @returns the copied value
*/
const handleCopy: FastCopy.Copier = (
value: any,
cache: FastCopy.Cache
): any => {
if (!value || typeof value !== 'object') {
return value
}

if (cache.has(value)) {
return cache.get(value)
}

const prototype = value.__proto__ || getPrototypeOf(value)
const Constructor = prototype && prototype.constructor

// plain objects
if (!Constructor || Constructor === realm.Object) {
return getObjectClone(value, realm, handleCopy, cache)
}

let clone: any

// arrays
if (isArray(value)) {
return getObjectCloneStrict(value, realm, handleCopy, cache)
}

// dates
if (value instanceof realm.Date) {
return new Constructor(value.getTime())
}

// regexps
if (value instanceof realm.RegExp) {
clone = new Constructor(
value.source,
value.flags || getRegExpFlags(value)
)

clone.lastIndex = value.lastIndex

return clone
}

// maps
if (realm.Map && value instanceof realm.Map) {
clone = new Constructor()
cache.set(value, clone)

value.forEach((value: any, key: any) => {
clone.set(key, handleCopy(value, cache))
})

return clone
}

// sets
if (realm.Set && value instanceof realm.Set) {
clone = new Constructor()
cache.set(value, clone)

value.forEach((value: any) => {
clone.add(handleCopy(value, cache))
})

return clone
}

// blobs
if (realm.Blob && value instanceof realm.Blob) {
return value.slice(0, value.size, value.type)
}

// buffers (node-only)
if (realm.Buffer && realm.Buffer.isBuffer(value)) {
clone = realm.Buffer.allocUnsafe
? realm.Buffer.allocUnsafe(value.length)
: new Constructor(value.length)

cache.set(value, clone)
value.copy(clone)

return clone
}

// arraybuffers / dataviews
if (realm.ArrayBuffer) {
// dataviews
if (realm.ArrayBuffer.isView(value)) {
clone = new Constructor(value.buffer.slice(0))
cache.set(value, clone)
return clone
}

// arraybuffers
if (value instanceof realm.ArrayBuffer) {
clone = value.slice(0)
cache.set(value, clone)
return clone
}
}

// if the value cannot / should not be cloned, don't
if (
// promise-like
typeof value.then === 'function' ||
// errors
value instanceof Error ||
// weakmaps
(realm.WeakMap && value instanceof realm.WeakMap) ||
// weaksets
(realm.WeakSet && value instanceof realm.WeakSet)
) {
return value
}

// assume anything left is a custom constructor
return getObjectClone(value, realm, handleCopy, cache)
}

return handleCopy(value, createCache())
}

/**
* @function strictCopy
*
* @description
* copy the value with `strict` option pre-applied
*
* @param value the value to copy
* @param [options] the options for copying with
* @param [options.realm] the realm (this) value the value is copied from
* @returns the copied value
*/
copy.strict = function strictCopy(value: any, options?: FastCopy.Options) {
return copy(value, {
isStrict: true,
realm: options ? options.realm : void 0,
})
}

export default copy
11 changes: 10 additions & 1 deletion packages/pinia/src/devtools/plugin.ts
Original file line number Diff line number Diff line change
@@ -27,7 +27,8 @@ import {
PINIA_ROOT_ID,
PINIA_ROOT_LABEL,
} from './formatting'
import { isPinia, toastMessage } from './utils'
import { isPinia, toastMessage, formatStateDifferences } from './utils'
import copy from './fastCopy'

// timeline can be paused when directly changing the state
let isTimelineActive = true
@@ -328,6 +329,8 @@ function addStoreToDevtools(app: DevtoolsApp, store: StoreGeneric) {
store.$onAction(({ after, onError, name, args }) => {
const groupId = runningActionId++

const initialState = copy(store.$state)

api.addTimelineEvent({
layerId: MUTATIONS_LAYER_ID,
event: {
@@ -337,6 +340,7 @@ function addStoreToDevtools(app: DevtoolsApp, store: StoreGeneric) {
data: {
store: formatDisplay(store.$id),
action: formatDisplay(name),
initialState,
args,
},
groupId,
@@ -345,6 +349,9 @@ function addStoreToDevtools(app: DevtoolsApp, store: StoreGeneric) {

after((result) => {
activeAction = undefined
const newState = store.$state
const differences = formatStateDifferences(initialState, newState)

api.addTimelineEvent({
layerId: MUTATIONS_LAYER_ID,
event: {
@@ -355,6 +362,8 @@ function addStoreToDevtools(app: DevtoolsApp, store: StoreGeneric) {
store: formatDisplay(store.$id),
action: formatDisplay(name),
args,
newState,
differences,
result,
},
groupId,
80 changes: 80 additions & 0 deletions packages/pinia/src/devtools/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Pinia } from '../rootStore'
import { StateTree } from '../types'

/**
* Shows a toast or console.log
@@ -26,3 +27,82 @@ export function toastMessage(
export function isPinia(o: any): o is Pinia {
return '_a' in o && 'install' in o
}

export const realTypeOf = (subject: any) => {
const type = typeof subject
if (type !== 'object') return type

if (subject === Math) {
return 'math'
} else if (subject === null) {
return 'null'
} else if (Array.isArray(subject)) {
return 'array'
} else if (Object.prototype.toString.call(subject) === '[object Date]') {
return 'date'
} else if (
typeof subject.toString === 'function' &&
/^\/.*\//.test(subject.toString())
) {
return 'regexp'
}
return 'object'
}

export function formatStateDifferences(
initialState: StateTree,
newState: StateTree
): StateTree {
const stateDifferences: StateTree = {}

for (const key in newState) {
const oldType = realTypeOf(initialState[key])
const newType = realTypeOf(newState[key])

if (oldType !== newType) {
stateDifferences[key] = newState[key]
continue
}

switch (newType) {
case 'object':
const oldHash = JSON.stringify(initialState[key])
const newHash = JSON.stringify(newState[key])

if (oldHash !== newHash) {
const diffsInObject = formatStateDifferences(
initialState[key],
newState[key]
)

if (Object.keys(diffsInObject).length) {
stateDifferences[key] = diffsInObject
}
}
break
case 'date':
if (initialState[key] - newState[key] !== 0) {
stateDifferences[key] = newState[key]
}
break
case 'array':
case 'regexp':
if (initialState[key].toString() !== newState[key].toString()) {
stateDifferences[key] = newState[key]
}
break
default:
if (initialState[key] !== newState[key]) {
stateDifferences[key] = newState[key]
}
}
}

Object.keys(initialState).forEach((key) => {
if (!(key in newState)) {
stateDifferences[key] = undefined
}
})

return stateDifferences
}