Skip to content
This repository has been archived by the owner on Mar 28, 2024. It is now read-only.

Commit

Permalink
NEXT-32745 - fix-circular-loop-in-serializer
Browse files Browse the repository at this point in the history
  • Loading branch information
Jannis Leifeld committed Feb 5, 2024
1 parent 136849c commit c357cc3
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 11 deletions.
15 changes: 13 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
"wait-on": "^6.0.1"
},
"dependencies": {
"json-complete": "^2.0.1",
"localforage": "^1.10.0",
"lodash": "^4.17.21"
}
Expand Down
4 changes: 2 additions & 2 deletions src/_internals/serializer/criteria-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import type { SerializerFactory } from './index';
const CriteriaSerializer: SerializerFactory = () => ({
name: 'criteria',

serialize: ({ value, customizerMethod }): any => {
serialize: ({ value, customizerMethod, seen }): any => {
if (value instanceof Criteria) {
return {
__type__: '__Criteria__',
data: customizerMethod(value.getCriteriaData()),
data: customizerMethod(value.getCriteriaData(), seen),
};
}
},
Expand Down
4 changes: 2 additions & 2 deletions src/_internals/serializer/entity-collection-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { SerializerFactory } from '.';
const EntityCollectionSerializerFactory: SerializerFactory = () => ({
name: 'entity-collection',

serialize: ({ value, customizerMethod }): any => {
serialize: ({ value, customizerMethod, seen }): any => {
if (value instanceof EntityCollection || (value?.__identifier__ && value.__identifier__() === 'EntityCollection')) {
return customizerMethod({
__type__: '__EntityCollection__',
Expand All @@ -17,7 +17,7 @@ const EntityCollectionSerializerFactory: SerializerFactory = () => ({
__entities__: Array.from(value),
__total__: value.total,
__aggregations__: value.aggregations,
});
}, seen);
}
},

Expand Down
119 changes: 119 additions & 0 deletions src/_internals/serializer/entity-serializer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,4 +160,123 @@ describe('entity-serializer.ts', () => {
expect(entityKeys).not.toContain('_isDirty');
expect(entityKeys).not.toContain('_isNew');
});

it('should convert entities with circular reference', () => {
jest.setTimeout(5000);

const originValues = {
string: 'jest-is-fun',
number: 42,
array: ['jest', 4, 'you'],
object: {
foo: 'bar',
},
lineItems: [
{
name: 'Item1',
price: 10,
children: [
{
name: 'Item1.1',
price: 2.5,
parent: undefined,
},
],
},
],
};

// Introduce circular reference
// @ts-expect-error
originValues.lineItems[0].children[0].parent = originValues.lineItems[0];

// @ts-expect-error - we know that this entity does not exist
const entity = new Entity('foo', 'jest', {
...cloneDeep(originValues),
}) as any;

entity.string = 'jest-is-more-fun';
entity.number = 1337;
entity.array.push('and me');
entity.object.foo = 'buz';
entity.parent = entity;

const messageData = {
entity,
};

// Check if private properties are hidden behind the proxy
const entityKeysBefore = Object.keys(messageData.entity);
expect(entityKeysBefore).not.toContain('_origin');
expect(entityKeysBefore).not.toContain('_isDirty');
expect(entityKeysBefore).not.toContain('_isNew');

const serializedMessageData = serialize(messageData);

expect(serializedMessageData.entity.hasOwnProperty('__id__')).toBe(true);
expect(serializedMessageData.entity.__id__).toBe('foo');

expect(serializedMessageData.entity.hasOwnProperty('__entityName__')).toBe(true);
expect(serializedMessageData.entity.__entityName__).toBe('jest');

expect(serializedMessageData.entity.hasOwnProperty('__isDirty__')).toBe(true);
expect(serializedMessageData.entity.__isDirty__).toBe(true);

expect(serializedMessageData.entity.hasOwnProperty('__isNew__')).toBe(true);
expect(serializedMessageData.entity.__isNew__).toBe(false);

expect(serializedMessageData.entity.hasOwnProperty('__origin__')).toBe(true);
expect(typeof serializedMessageData.entity.__origin__).toBe('object');

expect(serializedMessageData.entity.__origin__.hasOwnProperty('string')).toBe(true);
expect(serializedMessageData.entity.__origin__.string).toBe('jest-is-fun');

expect(serializedMessageData.entity.__origin__.hasOwnProperty('number')).toBe(true);
expect(serializedMessageData.entity.__origin__.number).toBe(42);

expect(serializedMessageData.entity.__origin__.hasOwnProperty('array')).toBe(true);
expect(serializedMessageData.entity.__origin__.array.length).toBe(3);

expect(serializedMessageData.entity.__origin__.hasOwnProperty('object')).toBe(true);
expect(typeof serializedMessageData.entity.__origin__.object).toBe('object');
expect(serializedMessageData.entity.__origin__.object.hasOwnProperty('foo')).toBe(true);
expect(serializedMessageData.entity.__origin__.object.foo).toBe('bar');

expect(serializedMessageData.entity.hasOwnProperty('__draft__')).toBe(true);
expect(typeof serializedMessageData.entity.__draft__).toBe('object');

expect(serializedMessageData.entity.__draft__.hasOwnProperty('string')).toBe(true);
expect(serializedMessageData.entity.__draft__.string).toBe('jest-is-more-fun');

expect(serializedMessageData.entity.__draft__.hasOwnProperty('number')).toBe(true);
expect(serializedMessageData.entity.__draft__.number).toBe(1337);

expect(serializedMessageData.entity.__draft__.hasOwnProperty('array')).toBe(true);
expect(serializedMessageData.entity.__draft__.array.length).toBe(4);

expect(serializedMessageData.entity.__draft__.hasOwnProperty('object')).toBe(true);
expect(typeof serializedMessageData.entity.__draft__.object).toBe('object');
expect(serializedMessageData.entity.__draft__.object.hasOwnProperty('foo')).toBe(true);
expect(serializedMessageData.entity.__draft__.object.foo).toBe('buz');

const deserializedMessageData = deserialize(serializedMessageData, new MessageEvent(''));

// Assert entity values
expect(deserializedMessageData.entity.getIsDirty()).toBe(true);
expect(deserializedMessageData.entity.isNew()).toBe(false);
expect(deserializedMessageData.entity.string).toBe('jest-is-more-fun');
expect(deserializedMessageData.entity.number).toBe(1337);
expect(deserializedMessageData.entity.array.length).toBe(4);
expect(deserializedMessageData.entity.array[0]).toBe('jest');
expect(deserializedMessageData.entity.array[1]).toBe(4);
expect(deserializedMessageData.entity.array[2]).toBe('you');
expect(deserializedMessageData.entity.array[3]).toBe('and me');
expect(deserializedMessageData.entity.object.foo).toBe('buz');

// Check if private properties are hidden behind the proxy
const entityKeys = Object.keys(deserializedMessageData.entity);
expect(entityKeys).not.toContain('_origin');
expect(entityKeys).not.toContain('_isDirty');
expect(entityKeys).not.toContain('_isNew');
});
});
6 changes: 3 additions & 3 deletions src/_internals/serializer/entity-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { SerializerFactory } from '.';
const EntitySerializerFactory: SerializerFactory = () => ({
name: 'entity',

serialize: ({ value, customizerMethod }): any => {
serialize: ({ value, customizerMethod, seen }): any => {
if (!isObject(value) || typeof value.__identifier__ !== 'function' || value.__identifier__() !== 'Entity') {
return;
}
Expand All @@ -17,8 +17,8 @@ const EntitySerializerFactory: SerializerFactory = () => ({
__entityName__: value._entityName,
__isDirty__: value._isDirty,
__isNew__: value._isNew,
__origin__: customizerMethod(value._origin),
__draft__: customizerMethod(value._draft),
__origin__: customizerMethod(value._origin, seen),
__draft__: customizerMethod(value._draft, seen),
};
},

Expand Down
26 changes: 24 additions & 2 deletions src/_internals/serializer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,21 @@ interface customizerProperties {
object: any | undefined,
stack: any,
event?: MessageEvent<string>,
customizerMethod: (messageData: any, seen: Map<any, any>, event?: MessageEvent<string>) => any,
seen: Map<any, any>,
}

interface deserializeCustomizerProperties extends Omit<
customizerProperties,
'customizerMethod' | 'seen'
> {
customizerMethod: (messageData: any, event?: MessageEvent<string>) => any,
}

interface serializer {
name: string,
serialize: (customizerProperties: customizerProperties) => any,
deserialize: (customizerProperties: customizerProperties) => any,
deserialize: (customizerProperties: deserializeCustomizerProperties) => any,
}

export type SerializerFactory = (dependencies: SerializerDependencies) => serializer;
Expand Down Expand Up @@ -63,8 +72,18 @@ export default function mainSerializerFactory(dependencies: SerializerDependenci
}

/* eslint-disable */
function serialize(messageData: any): any {
function serialize(messageData: any, seen = new Map()): any {
return cloneDeepWith<unknown>(messageData, (value, key, object, stack) => {
if (seen.has(value)) {
if (typeof seen.get(value) === 'string' && seen.get(value).startsWith('$#')) {
return;
}

return seen.get(value);
}

seen.set(value, `$#${Math.random()}`);

// return first matching serializer result
for (const serializer of serializers) {
const result = serializer.serialize({
Expand All @@ -73,9 +92,12 @@ export default function mainSerializerFactory(dependencies: SerializerDependenci
object,
stack,
customizerMethod: serialize,
seen,
});

if (result) {
// console.log('object', object)
seen.set(value, result)
return result;
};
}
Expand Down

0 comments on commit c357cc3

Please sign in to comment.