diff --git a/packages/overlayscrollbars/src/lifecycles/lifecycleBase.ts b/packages/overlayscrollbars/src/lifecycles/lifecycleBase.ts index 8d978a9..a03a726 100644 --- a/packages/overlayscrollbars/src/lifecycles/lifecycleBase.ts +++ b/packages/overlayscrollbars/src/lifecycles/lifecycleBase.ts @@ -8,6 +8,8 @@ import { validateOptions, assignDeep, createCache, + isEmptyObject, + isBoolean, } from 'support'; import { PlainObject } from 'typings'; @@ -44,17 +46,27 @@ export const createLifecycleBase = ( updateFunction: (changedOptions: OptionsValidated, changedCache: CacheUpdated) => any ): LifecycleBase => { const { _template: optionsTemplate, _options: defaultOptions } = transformOptions>(defaultOptionsWithTemplate); - const options: Required = assignDeep({}, defaultOptions, validateOptions(initialOptions || ({} as O), optionsTemplate)._validated); + const options: Required = assignDeep( + {}, + defaultOptions, + validateOptions(initialOptions || ({} as O), optionsTemplate, null, true)._validated + ); const cacheChange = createCache(cacheUpdateInfo); const update = (hints: LifecycleUpdateHints) => { + const hasForce = isBoolean(hints._force); const force = hints._force === true; - const changedCache = cacheChange(force ? undefined : hints._changedCache, force); - const changedOptions = force ? ({} as O) : hints._changedOptions || ({} as O); - updateFunction(changedOptions, changedCache); + const changedCache = cacheChange(force ? null : hints._changedCache || (hasForce ? null : []), force); + const changedOptions = force ? options : hints._changedOptions || ({} as O); + + if (!isEmptyObject(changedOptions) || !isEmptyObject(changedCache)) { + updateFunction(changedOptions, changedCache); + } }; + update({ _force: true }); + return { _options(newOptions?: O) { if (newOptions) { @@ -66,7 +78,7 @@ export const createLifecycleBase = ( return options; }, _update: (force?: boolean) => { - update({ _force: force }); + update({ _force: !!force }); }, _cacheChange: (cachePropsToUpdate?: CachePropsToUpdate) => { update({ _changedCache: cachePropsToUpdate }); diff --git a/packages/overlayscrollbars/src/support/cache/cache.ts b/packages/overlayscrollbars/src/support/cache/cache.ts index f27e827..66f9dd2 100644 --- a/packages/overlayscrollbars/src/support/cache/cache.ts +++ b/packages/overlayscrollbars/src/support/cache/cache.ts @@ -20,7 +20,7 @@ type EqualCachePropFunction = (a?: T[P], b?: T[P]) => bool export type CachePropsToUpdate = Array | keyof T; -export type CacheUpdate = (propsToUpdate?: CachePropsToUpdate, force?: boolean) => CacheUpdated; +export type CacheUpdate = (propsToUpdate?: CachePropsToUpdate | null, force?: boolean) => CacheUpdated; export type CacheUpdated = { [P in keyof T]?: T[P]; @@ -77,7 +77,7 @@ export const createCache = (cacheUpdateInfo: CacheUpdateInfo): CacheUpdate return result; }; - return (propsToUpdate?: CachePropsToUpdate, force?: boolean) => { + return (propsToUpdate, force) => { const finalPropsToUpdate: Array = (isString(propsToUpdate) ? ([propsToUpdate] as Array) : (propsToUpdate as Array)) || allProps; each(finalPropsToUpdate, (prop) => { diff --git a/packages/overlayscrollbars/src/support/options/validation.ts b/packages/overlayscrollbars/src/support/options/validation.ts index e4e5a84..be9a79b 100644 --- a/packages/overlayscrollbars/src/support/options/validation.ts +++ b/packages/overlayscrollbars/src/support/options/validation.ts @@ -1,4 +1,4 @@ -import { each, indexOf, hasOwnProperty, keys } from 'support/utils'; +import { each, hasOwnProperty, keys } from 'support/utils'; import { type, isArray, isUndefined, isEmptyObject, isPlainObject, isString } from 'support/utils/types'; import { OptionsTemplate, OptionsTemplateTypes, OptionsTemplateType, Func, OptionsValidationResult, OptionsValidated } from 'support/options'; import { PlainObject } from 'typings'; @@ -80,7 +80,13 @@ const validateRecursive = ( each(templateValueArr, (currTemplateType) => { // if currType value isn't inside possibleTemplateTypes we assume its a enum string value - const isEnumString = indexOf(Object.values(optionsTemplateTypes), currTemplateType) < 0; + let typeString: string | undefined; + each(optionsTemplateTypes, (value: string, key: string) => { + if (value === currTemplateType) { + typeString = key; + } + }); + const isEnumString = typeString === undefined; if (isEnumString && isString(optionsValue)) { // split it into a array which contains all possible values for example: ["yes", "no", "maybe"] const enumStringSplit = currTemplateType.split(' '); @@ -93,7 +99,7 @@ const validateRecursive = ( } // build error message - errorPossibleTypes.push(isEnumString ? optionsTemplateTypes.string : currTemplateType); + errorPossibleTypes.push(isEnumString ? optionsTemplateTypes.string : typeString!); // continue if invalid, break if valid return !isValid; @@ -143,7 +149,7 @@ const validateRecursive = ( const validateOptions = ( options: T, template: OptionsTemplate>, - optionsDiff?: T, + optionsDiff?: T | null, doWriteErrors?: boolean ): OptionsValidationResult => { /* diff --git a/packages/overlayscrollbars/tests/jsdom/lifecycles/lifecycleBase.test.ts b/packages/overlayscrollbars/tests/jsdom/lifecycles/lifecycleBase.test.ts new file mode 100644 index 0000000..8c529c7 --- /dev/null +++ b/packages/overlayscrollbars/tests/jsdom/lifecycles/lifecycleBase.test.ts @@ -0,0 +1,242 @@ +import { optionsTemplateTypes as oTypes } from 'support'; +import { createLifecycleBase } from 'lifecycles/lifecycleBase'; + +interface TestLifecycleOptions { + number?: number; + string?: string; + nested?: { + boolean?: boolean; + number?: number; + }; +} +interface TestLifecycleCache { + number?: number; + constant?: boolean; + object?: { + string?: string; + boolean?: boolean; + }; +} + +const createLifecycle = (initalOptions?: TestLifecycleOptions, updateFn?: () => any) => + createLifecycleBase( + { + number: [0, oTypes.number], + string: ['hi', oTypes.string], + nested: { + boolean: [false, oTypes.boolean], + number: [0, oTypes.number], + }, + }, + { + number: (current) => (current || 0) + 1, + constant: () => false, + object: (current) => ({ string: `${current?.string || ''}hi`, boolean: !current?.boolean }), + }, + initalOptions, + updateFn || (() => {}) + ); + +describe('lifecycleBase', () => { + describe('options', () => { + test('correct default options', () => { + const { _options } = createLifecycle(); + + const defaultOptions = _options(); + expect(defaultOptions.number).toBe(0); + expect(defaultOptions.string).toBe('hi'); + expect(defaultOptions.nested?.boolean).toBe(false); + expect(defaultOptions.nested?.number).toBe(0); + }); + + test('correct initial options', () => { + const { _options } = createLifecycle({ number: 1, nested: { boolean: true } }); + + const initOptions = _options(); + expect(initOptions.number).toBe(1); + expect(initOptions.string).toBe('hi'); + expect(initOptions.nested?.boolean).toBe(true); + expect(initOptions.nested?.number).toBe(0); + }); + + test('correct options change', () => { + const { _options } = createLifecycle(); + + const options = _options(); + expect(options.number).toBe(0); + expect(options.string).toBe('hi'); + expect(options.nested?.boolean).toBe(false); + expect(options.nested?.number).toBe(0); + + const changedOptions = _options({ number: 2, nested: { number: 3 } }); + expect(changedOptions.number).toBe(2); + expect(changedOptions.string).toBe('hi'); + expect(changedOptions.nested?.boolean).toBe(false); + expect(changedOptions.nested?.number).toBe(3); + }); + + test('correct options validation', () => { + const originalWarn = console.warn; + const mockWarn = jest.fn(); + console.warn = mockWarn; + + // @ts-ignore + const { _options } = createLifecycle({ string: 123 }); + expect(mockWarn).toBeCalledTimes(1); + + const options = _options(); + expect(options.string).toBe('hi'); + + // @ts-ignore + const changedOptions = _options({ number: 'string', nested: null }); + expect(mockWarn).toBeCalledTimes(2); + expect(changedOptions.number).toBe(0); + expect(changedOptions.string).toBe('hi'); + expect(changedOptions.nested?.boolean).toBe(false); + expect(changedOptions.nested?.number).toBe(0); + + console.warn = originalWarn; + }); + }); + + describe('cache', () => { + test('single value cache change', () => { + const updateFn = jest.fn(); + const { _cacheChange } = createLifecycle({}, updateFn); + + _cacheChange('number'); + expect(updateFn).toBeCalledTimes(2); + expect(updateFn).toBeCalledWith({}, { number: 2 }); + + _cacheChange('constant'); + expect(updateFn).toBeCalledTimes(2); + }); + + test('multiple value cache change', () => { + const updateFn = jest.fn(); + const { _cacheChange } = createLifecycle({}, updateFn); + + _cacheChange(['number', 'object']); + expect(updateFn).toBeCalledTimes(2); + expect(updateFn).toBeCalledWith({}, { number: 2, object: { string: 'hihi', boolean: false } }); + + _cacheChange(['number', 'constant']); + expect(updateFn).toBeCalledTimes(3); + expect(updateFn).toBeCalledWith({}, { number: 3 }); + + _cacheChange(['constant']); + expect(updateFn).toBeCalledTimes(3); + }); + }); + + describe('update', () => { + test('initial call', () => { + const updateFn = jest.fn(); + createLifecycle({}, updateFn); + + expect(updateFn).toBeCalledTimes(1); + expect(updateFn).toBeCalledWith( + { + number: 0, + string: 'hi', + nested: { + boolean: false, + number: 0, + }, + }, + { + number: 1, + constant: false, + object: { + string: 'hi', + boolean: true, + }, + } + ); + }); + + test('updates correctly on options change', () => { + const updateFn = jest.fn(); + const { _options } = createLifecycle({}, updateFn); + + _options({ number: 5 }); + expect(updateFn).toBeCalledTimes(2); + expect(updateFn).toBeCalledWith({ number: 5 }, {}); + + _options({ number: 5, string: 'test', nested: { number: 3 } }); + expect(updateFn).toBeCalledTimes(3); + expect(updateFn).toBeCalledWith({ string: 'test', nested: { number: 3 } }, {}); + + _options({ number: 5, string: 'test', nested: { number: 3 } }); + expect(updateFn).toBeCalledTimes(3); + }); + + test('updates correctly on cache change', () => { + const updateFn = jest.fn(); + const { _cacheChange } = createLifecycle({}, updateFn); + + _cacheChange('number'); + expect(updateFn).toBeCalledTimes(2); + expect(updateFn).toBeCalledWith({}, { number: 2 }); + + _cacheChange(['number', 'object', 'constant']); + expect(updateFn).toBeCalledTimes(3); + expect(updateFn).toBeCalledWith({}, { number: 3, object: { string: 'hihi', boolean: false } }); + + _cacheChange('constant'); + expect(updateFn).toBeCalledTimes(3); + }); + + test('updates correctly on update call', () => { + const updateFn = jest.fn(); + const { _update, _options } = createLifecycle({}, updateFn); + + _update(); + expect(updateFn).toBeCalledTimes(2); + expect(updateFn).toBeCalledWith({}, { number: 2, object: { string: 'hihi', boolean: false } }); + + _update(true); + expect(updateFn).toBeCalledTimes(3); + expect(updateFn).toBeCalledWith( + { + number: 0, + string: 'hi', + nested: { + boolean: false, + number: 0, + }, + }, + { + number: 3, + constant: false, + object: { + string: 'hihihi', + boolean: true, + }, + } + ); + + _options({ number: 3, nested: { boolean: true } }); + _update(true); + expect(updateFn).toBeCalledTimes(5); + expect(updateFn).toBeCalledWith( + { + number: 3, + string: 'hi', + nested: { + boolean: true, + number: 0, + }, + }, + { + number: 4, + constant: false, + object: { + string: 'hihihihi', + boolean: false, + }, + } + ); + }); + }); +}); diff --git a/packages/overlayscrollbars/tests/jsdom/support/cache/cache.test.ts b/packages/overlayscrollbars/tests/jsdom/support/cache/cache.test.ts index 189f3b2..2ac3669 100644 --- a/packages/overlayscrollbars/tests/jsdom/support/cache/cache.test.ts +++ b/packages/overlayscrollbars/tests/jsdom/support/cache/cache.test.ts @@ -201,5 +201,22 @@ describe('cache', () => { expect(updateNumberFn).toHaveBeenCalledWith(2, 1); expect(Object.prototype.hasOwnProperty.call(updateCache('number'), 'number')).toBe(false); }); + + test('updates all entries with null or undefined as argument', () => { + const [updateNumberFn, updateNumber] = createUpdater((i) => i); + const [updateNumberFn2, updateNumber2] = createUpdater((i) => i); + const updateCache = createCache({ + number: updateNumber, + number2: updateNumber2, + }); + + updateCache(); + expect(updateNumberFn).toHaveBeenCalledWith(undefined, undefined); + expect(updateNumberFn2).toHaveBeenCalledWith(undefined, undefined); + + updateCache(null); + expect(updateNumberFn).toHaveBeenCalledWith(1, undefined); + expect(updateNumberFn2).toHaveBeenCalledWith(1, undefined); + }); }); });