From 99f7c84695160e05c29dcf2f38ce6d916d5f21ee Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Thu, 22 Jul 2021 12:04:24 +0200 Subject: [PATCH] fix: decycle potentially cyclic structures before serializing (#634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: François Chalifour --- .../src/__tests__/getSources.test.ts | 23 ++++++++++++++++++ packages/autocomplete-core/src/resolve.ts | 6 ++--- .../__tests__/getNormalizedSources.test.ts | 19 +++++++++++++++ .../src/utils/getNormalizedSources.ts | 9 +++---- .../src/__tests__/decycle.test.ts | 24 +++++++++++++++++++ .../src/__tests__/invariant.test.ts | 17 +++++++++++++ packages/autocomplete-shared/src/decycle.ts | 23 ++++++++++++++++++ packages/autocomplete-shared/src/index.ts | 1 + packages/autocomplete-shared/src/invariant.ts | 9 +++++-- 9 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 packages/autocomplete-shared/src/__tests__/decycle.test.ts create mode 100644 packages/autocomplete-shared/src/decycle.ts diff --git a/packages/autocomplete-core/src/__tests__/getSources.test.ts b/packages/autocomplete-core/src/__tests__/getSources.test.ts index 9cbd5b795..bf27cd79d 100644 --- a/packages/autocomplete-core/src/__tests__/getSources.test.ts +++ b/packages/autocomplete-core/src/__tests__/getSources.test.ts @@ -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'); diff --git a/packages/autocomplete-core/src/resolve.ts b/packages/autocomplete-core/src/resolve.ts index 9e83ed186..68bb97a5b 100644 --- a/packages/autocomplete-core/src/resolve.ts +++ b/packages/autocomplete-core/src/resolve.ts @@ -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, @@ -172,11 +172,11 @@ export function postResolve( 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` ); diff --git a/packages/autocomplete-core/src/utils/__tests__/getNormalizedSources.test.ts b/packages/autocomplete-core/src/utils/__tests__/getNormalizedSources.test.ts index 6b141e0c1..1a5458b35 100644 --- a/packages/autocomplete-core/src/utils/__tests__/getNormalizedSources.test.ts +++ b/packages/autocomplete-core/src/utils/__tests__/getNormalizedSources.test.ts @@ -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 = () => [ { diff --git a/packages/autocomplete-core/src/utils/getNormalizedSources.ts b/packages/autocomplete-core/src/utils/getNormalizedSources.ts index 19a07047c..52b63491b 100644 --- a/packages/autocomplete-core/src/utils/getNormalizedSources.ts +++ b/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, @@ -20,9 +20,10 @@ export function getNormalizedSources( 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( diff --git a/packages/autocomplete-shared/src/__tests__/decycle.test.ts b/packages/autocomplete-shared/src/__tests__/decycle.test.ts new file mode 100644 index 000000000..77a8e7168 --- /dev/null +++ b/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]' }); + }); + } +}); diff --git a/packages/autocomplete-shared/src/__tests__/invariant.test.ts b/packages/autocomplete-shared/src/__tests__/invariant.test.ts index b6bbaf40d..eeaea4e59 100644 --- a/packages/autocomplete-shared/src/__tests__/invariant.test.ts +++ b/packages/autocomplete-shared/src/__tests__/invariant.test.ts @@ -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(); + }); } }); diff --git a/packages/autocomplete-shared/src/decycle.ts b/packages/autocomplete-shared/src/decycle.ts new file mode 100644 index 000000000..d65145622 --- /dev/null +++ b/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)]) + ); +} diff --git a/packages/autocomplete-shared/src/index.ts b/packages/autocomplete-shared/src/index.ts index db437a688..6a4a7de39 100644 --- a/packages/autocomplete-shared/src/index.ts +++ b/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'; diff --git a/packages/autocomplete-shared/src/invariant.ts b/packages/autocomplete-shared/src/invariant.ts index 86631594e..6b6468ca8 100644 --- a/packages/autocomplete-shared/src/invariant.ts +++ b/packages/autocomplete-shared/src/invariant.ts @@ -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}` + ); } }