diff --git a/addons/actions/package.json b/addons/actions/package.json index 1002a204efb2..bbcf69f16c95 100644 --- a/addons/actions/package.json +++ b/addons/actions/package.json @@ -23,7 +23,6 @@ "dependencies": { "@storybook/addons": "^3.3.0-alpha.0", "deep-equal": "^1.0.1", - "json-stringify-safe": "^5.0.1", "prop-types": "^15.5.10", "react-inspector": "^2.1.1", "uuid": "^3.1.0" diff --git a/addons/actions/src/components/ActionLogger/NodeRenderer.js b/addons/actions/src/components/ActionLogger/NodeRenderer.js new file mode 100644 index 000000000000..2b366655fc1f --- /dev/null +++ b/addons/actions/src/components/ActionLogger/NodeRenderer.js @@ -0,0 +1,30 @@ +import React, { PropTypes } from 'react'; +import { ObjectLabel, ObjectRootLabel } from 'react-inspector'; +import { CLASS_NAME_KEY, isObject, createFakeConstructor } from '../../util'; + +export default function NodeRenderer({ depth, name, data, isNonEnumerable }) { + let obj; + if (isObject(data) && data[CLASS_NAME_KEY]) { + obj = createFakeConstructor(data); + } else { + obj = data; + } + + return depth === 0 ? ( + + ) : ( + + ); +} + +NodeRenderer.propTypes = { + depth: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + data: PropTypes.any, // eslint-disable-line react/forbid-prop-types + isNonEnumerable: PropTypes.bool, +}; + +NodeRenderer.defaultProps = { + data: undefined, + isNonEnumerable: false, +}; diff --git a/addons/actions/src/components/ActionLogger/index.js b/addons/actions/src/components/ActionLogger/index.js index bd66f420af53..7f66421f9038 100644 --- a/addons/actions/src/components/ActionLogger/index.js +++ b/addons/actions/src/components/ActionLogger/index.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Inspector from 'react-inspector'; import style from './style'; +import NodeRenderer from './NodeRenderer'; class ActionLogger extends Component { getActionData() { @@ -15,7 +16,9 @@ class ActionLogger extends Component {
{action.count > 1 && counter}
diff --git a/addons/actions/src/containers/ActionLogger/index.js b/addons/actions/src/containers/ActionLogger/index.js index f3c1e19a9af0..853f25eee008 100644 --- a/addons/actions/src/containers/ActionLogger/index.js +++ b/addons/actions/src/containers/ActionLogger/index.js @@ -3,6 +3,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import deepEqual from 'deep-equal'; +import { CYCLIC_KEY, isObject, retrocycle } from '../../util'; import ActionLoggerComponent from '../../components/ActionLogger/'; import { EVENT_ID } from '../../'; @@ -23,10 +24,12 @@ export default class ActionLogger extends React.Component { } addAction(action) { - action.data.args = action.data.args.map(arg => JSON.parse(arg)); // eslint-disable-line + action.data.args = action.data.args.map(arg => retrocycle(JSON.parse(arg))); // eslint-disable-line + const isCyclic = !!action.data.args.find(arg => isObject(arg) && arg[CYCLIC_KEY]); const actions = [...this.state.actions]; const previous = actions.length && actions[0]; - if (previous && deepEqual(previous.data, action.data, { strict: true })) { + + if (previous && !isCyclic && deepEqual(previous.data, action.data, { strict: true })) { previous.count++; // eslint-disable-line } else { action.count = 1; // eslint-disable-line diff --git a/addons/actions/src/preview.js b/addons/actions/src/preview.js index fa722f3effe6..bf1b526b9da7 100644 --- a/addons/actions/src/preview.js +++ b/addons/actions/src/preview.js @@ -1,21 +1,14 @@ /* eslint-disable no-underscore-dangle */ import addons from '@storybook/addons'; -import stringify from 'json-stringify-safe'; import uuid from 'uuid/v1'; import { EVENT_ID } from './'; - -function _format(arg) { - if (arg && typeof arg.preventDefault !== 'undefined') { - return stringify('[SyntheticEvent]'); - } - return stringify(arg); -} +import { decycle } from './util'; export function action(name) { // eslint-disable-next-line no-unused-vars, func-names const handler = function(..._args) { - const args = Array.from(_args).map(_format); + const args = Array.from(_args).map(arg => JSON.stringify(decycle(arg))); const channel = addons.getChannel(); const id = uuid(); channel.emit(EVENT_ID, { diff --git a/addons/actions/src/preview.test.js b/addons/actions/src/preview.test.js index be8154c2971a..c1b537352a42 100644 --- a/addons/actions/src/preview.test.js +++ b/addons/actions/src/preview.test.js @@ -23,5 +23,17 @@ describe('preview', () => { expect(channel.emit.mock.calls[0][1].id).toBe('42'); expect(channel.emit.mock.calls[1][1].id).toBe('24'); }); + it('should be able to handle cyclic object without hanging', () => { + const cyclicObject = { + propertyA: { + innerPropertyA: {}, + }, + propertyB: 'b', + }; + cyclicObject.propertyA.innerPropertyA = cyclicObject; + + expect(() => JSON.stringify(cyclicObject)).toThrow(); + expect(() => action('foo')(cyclicObject)).not.toThrow(); + }); }); }); diff --git a/addons/actions/src/util.js b/addons/actions/src/util.js new file mode 100644 index 000000000000..baedba13ed0d --- /dev/null +++ b/addons/actions/src/util.js @@ -0,0 +1,117 @@ +export const CLASS_NAME_KEY = '$___storybook.className'; +export const CYCLIC_KEY = '$___storybook.isCyclic'; + +export function muteProperties(keys, value) { + keys.forEach(key => Object.defineProperty(value, key, { enumerable: false })); +} + +export function isObject(value) { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +export function createFakeConstructor(obj) { + function FakeConstructor(data) { + Object.assign(this, data); + } + + Object.defineProperty(FakeConstructor, 'name', { + value: obj[CLASS_NAME_KEY], + }); + + return new FakeConstructor(obj); +} + +// Based on: https://github.com/douglascrockford/JSON-js/blob/master/cycle.js +export function decycle(object, depth = 15) { + const objects = new WeakMap(); + let isCyclic = false; + + return (function derez(value, path, _depth) { + let oldPath; + let obj; + + if (Object(value) === value && _depth > depth) { + return `[${value.constructor.name}...]`; + } + + if ( + typeof value === 'object' && + value !== null && + !(value instanceof Boolean) && + !(value instanceof Date) && + !(value instanceof Number) && + !(value instanceof RegExp) && + !(value instanceof String) + ) { + oldPath = objects.get(value); + if (oldPath !== undefined) { + isCyclic = true; + + return { $ref: oldPath }; + } + + objects.set(value, path); + + if (Array.isArray(value)) { + obj = []; + for (let i = 0; i < value.length; i += 1) { + obj[i] = derez(value[i], `${path}[${i}]`, _depth + 1); + } + } else { + obj = { [CLASS_NAME_KEY]: value.constructor ? value.constructor.name : 'Object' }; + + Object.keys(value).forEach(name => { + obj[name] = derez(value[name], `${path}[${JSON.stringify(name)}]`, _depth + 1); + }); + } + + if (_depth === 0 && isObject(value) && isCyclic) { + obj[CYCLIC_KEY] = true; + } + + return obj; + } + + return value; + })(object, '$', 0); +} + +export function retrocycle($) { + const pathReg = /^\$(?:\[(?:\d+|"(?:[^\\"\u0000-\u001f]|\\([\\"/bfnrt]|u[0-9a-zA-Z]{4}))*")])*$/; + + (function rez(value) { + if (value && typeof value === 'object') { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i += 1) { + const item = value[i]; + if (item && typeof item === 'object') { + const path = item.$ref; + if (typeof path === 'string' && pathReg.test(path)) { + value[i] = eval(path); // eslint-disable-line no-eval, no-param-reassign + } else { + rez(item); + } + } + } + } else { + muteProperties([CLASS_NAME_KEY, CYCLIC_KEY], value); + + Object.keys(value).forEach(name => { + const item = value[name]; + + if (typeof item === 'object' && item !== null) { + const path = item.$ref; + + if (typeof path === 'string' && pathReg.test(path)) { + value[name] = eval(path); // eslint-disable-line no-eval, no-param-reassign + } else { + rez(item); + } + } + }); + } + } + })($); + + return $; +}