Skip to content
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

Circular json can possibly hang #1881

Merged
merged 12 commits into from
Sep 22, 2017
1 change: 0 additions & 1 deletion addons/actions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 30 additions & 0 deletions addons/actions/src/components/ActionLogger/NodeRenderer.js
Original file line number Diff line number Diff line change
@@ -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 ? (
<ObjectRootLabel name={name} data={obj} />
) : (
<ObjectLabel name={name} data={obj} isNonEnumerable={isNonEnumerable} />
);
}

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,
};
5 changes: 4 additions & 1 deletion addons/actions/src/components/ActionLogger/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -15,7 +16,9 @@ class ActionLogger extends Component {
<div style={style.countwrap}>{action.count > 1 && counter}</div>
<div style={style.inspector}>
<Inspector
showNonenumerable
nodeRenderer={NodeRenderer}
sortObjectKeys
showNonenumerable={false}
name={action.data.name}
data={action.data.args || action.data}
/>
Expand Down
7 changes: 5 additions & 2 deletions addons/actions/src/containers/ActionLogger/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '../../';
Expand All @@ -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
Expand Down
11 changes: 2 additions & 9 deletions addons/actions/src/preview.js
Original file line number Diff line number Diff line change
@@ -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, {
Expand Down
12 changes: 12 additions & 0 deletions addons/actions/src/preview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
117 changes: 117 additions & 0 deletions addons/actions/src/util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
export const CLASS_NAME_KEY = '$___storybook.className';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might this be it's own npm module maybe?

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