Skip to content

Commit

Permalink
fix: decycle potentially cyclic structures before serializing (#634)
Browse files Browse the repository at this point in the history
Co-authored-by: François Chalifour <francoischalifour@users.noreply.github.com>
  • Loading branch information
sarahdayan and francoischalifour committed Jul 22, 2021
1 parent 5e02c18 commit 99f7c84
Show file tree
Hide file tree
Showing 9 changed files with 122 additions and 9 deletions.
23 changes: 23 additions & 0 deletions packages/autocomplete-core/src/__tests__/getSources.test.ts
Expand Up @@ -112,6 +112,29 @@ describe('getSources', () => {
expect(onStateChange.mock.calls.pop()[0].state.collections).toHaveLength(2);
});

test('with circular references returned from getItems does not throw', () => {
const { inputElement } = createPlayground(createAutocomplete, {
getSources() {
return [
createSource({
sourceId: 'source1',
getItems: () => {
const circular = { a: 'b', self: null };
circular.self = circular;

return [circular];
},
}),
];
},
});

expect(() => {
inputElement.focus();
userEvent.type(inputElement, 'a');
}).not.toThrow();
});

test('with nothing returned from getItems throws', async () => {
const spy = jest.spyOn(handlers, 'onInput');

Expand Down
6 changes: 3 additions & 3 deletions packages/autocomplete-core/src/resolve.ts
Expand Up @@ -4,7 +4,7 @@ import type {
RequesterDescription,
TransformResponse,
} from '@algolia/autocomplete-preset-algolia';
import { invariant } from '@algolia/autocomplete-shared';
import { decycle, invariant } from '@algolia/autocomplete-shared';
import {
MultipleQueriesQuery,
SearchForFacetValuesResponse,
Expand Down Expand Up @@ -172,11 +172,11 @@ export function postResolve<TItem extends BaseItem>(

invariant(
Array.isArray(items),
`The \`getItems\` function from source "${
() => `The \`getItems\` function from source "${
source.sourceId
}" must return an array of items but returned type ${JSON.stringify(
typeof items
)}:\n\n${JSON.stringify(items, null, 2)}.
)}:\n\n${JSON.stringify(decycle(items), null, 2)}.
See: https://www.algolia.com/doc/ui-libraries/autocomplete/core-concepts/sources/#param-getitems`
);
Expand Down
Expand Up @@ -66,6 +66,25 @@ describe('getNormalizedSources', () => {
);
});

test('with wrong `getSources` function return type containing circular references triggers invariant', async () => {
const circular = { self: null };
circular.self = circular;

const getSources = () => circular;
const params = {
query: '',
state: createState({}),
...createScopeApi(),
};

// @ts-expect-error
await expect(getNormalizedSources(getSources, params)).rejects.toEqual(
new Error(
'[Autocomplete] The `getSources` function must return an array of sources but returned type "object":\n\n{\n "self": "[Circular]"\n}'
)
);
});

test('with missing `sourceId` triggers invariant', async () => {
const getSources = () => [
{
Expand Down
9 changes: 5 additions & 4 deletions packages/autocomplete-core/src/utils/getNormalizedSources.ts
@@ -1,4 +1,4 @@
import { invariant } from '@algolia/autocomplete-shared';
import { invariant, decycle } from '@algolia/autocomplete-shared';

import {
AutocompleteSource,
Expand All @@ -20,9 +20,10 @@ export function getNormalizedSources<TItem extends BaseItem>(
return Promise.resolve(getSources(params)).then((sources) => {
invariant(
Array.isArray(sources),
`The \`getSources\` function must return an array of sources but returned type ${JSON.stringify(
typeof sources
)}:\n\n${JSON.stringify(sources, null, 2)}`
() =>
`The \`getSources\` function must return an array of sources but returned type ${JSON.stringify(
typeof sources
)}:\n\n${JSON.stringify(decycle(sources), null, 2)}`
);

return Promise.all(
Expand Down
24 changes: 24 additions & 0 deletions packages/autocomplete-shared/src/__tests__/decycle.test.ts
@@ -0,0 +1,24 @@
import { decycle } from '../decycle';

describe('decycle', () => {
if (__DEV__) {
test('leaves objects with no circular references intact', () => {
const ref = { a: 1 };
const obj = {
a: 'b',
c: { d: [ref, () => {}, null, false, undefined] },
};

expect(decycle(obj)).toEqual({
a: 'b',
c: { d: [{ a: 1 }, expect.any(Function), null, false, undefined] },
});
});
test('replaces circular references', () => {
const circular = { a: 'b', self: null };
circular.self = circular;

expect(decycle(circular)).toEqual({ a: 'b', self: '[Circular]' });
});
}
});
17 changes: 17 additions & 0 deletions packages/autocomplete-shared/src/__tests__/invariant.test.ts
Expand Up @@ -13,5 +13,22 @@ describe('invariant', () => {
invariant(true, 'invariant');
}).not.toThrow();
});

test('lazily instantiates message', () => {
const spy1 = jest.fn(() => 'invariant');
const spy2 = jest.fn(() => 'invariant');

expect(() => {
invariant(false, spy1);
}).toThrow('[Autocomplete] invariant');

expect(spy1).toHaveBeenCalledTimes(1);

expect(() => {
invariant(true, spy2);
}).not.toThrow('[Autocomplete] invariant');

expect(spy2).not.toHaveBeenCalled();
});
}
});
23 changes: 23 additions & 0 deletions packages/autocomplete-shared/src/decycle.ts
@@ -0,0 +1,23 @@
/**
* Decycles objects with circular references.
* This is used to print cyclic structures in development environment only.
*/
export function decycle(obj: any, seen = new Set()) {
if (!__DEV__ || !obj || typeof obj !== 'object') {
return obj;
}

if (seen.has(obj)) {
return '[Circular]';
}

const newSeen = seen.add(obj);

if (Array.isArray(obj)) {
return obj.map((x) => decycle(x, newSeen));
}

return Object.fromEntries(
Object.entries(obj).map(([key, value]) => [key, decycle(value, newSeen)])
);
}
1 change: 1 addition & 0 deletions packages/autocomplete-shared/src/index.ts
@@ -1,5 +1,6 @@
export * from './createRef';
export * from './debounce';
export * from './decycle';
export * from './generateAutocompleteId';
export * from './getAttributeValueByPath';
export * from './getItemsCount';
Expand Down
9 changes: 7 additions & 2 deletions packages/autocomplete-shared/src/invariant.ts
Expand Up @@ -3,12 +3,17 @@
* This is used to make development a better experience to provide guidance as
* to where the error comes from.
*/
export function invariant(condition: boolean, message: string) {
export function invariant(
condition: boolean,
message: string | (() => string)
) {
if (!__DEV__) {
return;
}

if (!condition) {
throw new Error(`[Autocomplete] ${message}`);
throw new Error(
`[Autocomplete] ${typeof message === 'function' ? message() : message}`
);
}
}

0 comments on commit 99f7c84

Please sign in to comment.