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 $;
+}