diff --git a/packages/pinia/__tests__/devtools/utils.spec.ts b/packages/pinia/__tests__/devtools/utils.spec.ts new file mode 100644 index 0000000000..cd1c3e787e --- /dev/null +++ b/packages/pinia/__tests__/devtools/utils.spec.ts @@ -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, + }) + }) + }) +}) diff --git a/packages/pinia/src/devtools/fastCopy.ts b/packages/pinia/src/devtools/fastCopy.ts new file mode 100644 index 0000000000..780f7a241b --- /dev/null +++ b/packages/pinia/src/devtools/fastCopy.ts @@ -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 + + 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: Value, cache: Cache) => Value + + export type ObjectCloner = ( + 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, 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 diff --git a/packages/pinia/src/devtools/plugin.ts b/packages/pinia/src/devtools/plugin.ts index d62ac5f0d8..cabe6216fd 100644 --- a/packages/pinia/src/devtools/plugin.ts +++ b/packages/pinia/src/devtools/plugin.ts @@ -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, diff --git a/packages/pinia/src/devtools/utils.ts b/packages/pinia/src/devtools/utils.ts index 07718b668c..61de32f95d 100644 --- a/packages/pinia/src/devtools/utils.ts +++ b/packages/pinia/src/devtools/utils.ts @@ -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 +}