From 921788ce1067da9e8d42fd5dd2c688db659b9c88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 10 Dec 2020 11:24:23 +0100 Subject: [PATCH] feat(js): introduce Update API --- bundlesize.config.json | 2 +- .../src/types/AutocompleteOptions.ts | 6 +- packages/autocomplete-js/src/autocomplete.ts | 243 +++++++++--------- .../src/createEffectWrapper.ts | 10 + .../src/createReactiveWrapper.ts | 44 ++++ .../autocomplete-js/src/defaultRenderer.ts | 5 + .../autocomplete-js/src/getDefaultOptions.ts | 45 ++++ packages/autocomplete-js/src/render.ts | 8 +- .../src/types/AutocompleteApi.ts | 6 + .../src/types/AutocompleteOptions.ts | 4 + packages/autocomplete-js/src/utils/index.ts | 1 + .../autocomplete-js/src/utils/mergeDeep.ts | 20 ++ 12 files changed, 269 insertions(+), 125 deletions(-) create mode 100644 packages/autocomplete-js/src/createReactiveWrapper.ts create mode 100644 packages/autocomplete-js/src/defaultRenderer.ts create mode 100644 packages/autocomplete-js/src/getDefaultOptions.ts create mode 100644 packages/autocomplete-js/src/utils/mergeDeep.ts diff --git a/bundlesize.config.json b/bundlesize.config.json index aac214076..f96fb66bd 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -6,7 +6,7 @@ }, { "path": "packages/autocomplete-js/dist/umd/index.production.js", - "maxSize": "9.25 kB" + "maxSize": "9.75 kB" }, { "path": "packages/autocomplete-preset-algolia/dist/umd/index.production.js", diff --git a/packages/autocomplete-core/src/types/AutocompleteOptions.ts b/packages/autocomplete-core/src/types/AutocompleteOptions.ts index 464c8b27f..086d94fff 100644 --- a/packages/autocomplete-core/src/types/AutocompleteOptions.ts +++ b/packages/autocomplete-core/src/types/AutocompleteOptions.ts @@ -10,15 +10,15 @@ import { } from './AutocompleteSource'; import { AutocompleteState } from './AutocompleteState'; -interface OnSubmitParams +export interface OnSubmitParams extends AutocompleteScopeApi { state: AutocompleteState; event: any; } -type OnResetParams = OnSubmitParams; +export type OnResetParams = OnSubmitParams; -interface OnInputParams +export interface OnInputParams extends AutocompleteScopeApi { query: string; state: AutocompleteState; diff --git a/packages/autocomplete-js/src/autocomplete.ts b/packages/autocomplete-js/src/autocomplete.ts index 8aa9170f8..3b8b1361f 100644 --- a/packages/autocomplete-js/src/autocomplete.ts +++ b/packages/autocomplete-js/src/autocomplete.ts @@ -7,6 +7,8 @@ import { createRef, debounce, invariant } from '@algolia/autocomplete-shared'; import { createAutocompleteDom } from './createAutocompleteDom'; import { createEffectWrapper } from './createEffectWrapper'; +import { createReactiveWrapper } from './createReactiveWrapper'; +import { getDefaultOptions } from './getDefaultOptions'; import { getPanelPositionStyle } from './getPanelPositionStyle'; import { render } from './render'; import { @@ -15,46 +17,29 @@ import { AutocompletePropGetters, AutocompleteState, } from './types'; -import { getHTMLElement, setProperties } from './utils'; +import { getHTMLElement, mergeDeep, setProperties } from './utils'; -function defaultRenderer({ root, sections }) { - for (const section of sections) { - root.appendChild(section); - } -} +export function autocomplete( + options: AutocompleteOptions +): AutocompleteApi { + const { runEffect, cleanupEffects, runEffects } = createEffectWrapper(); + const { reactive, runReactives } = createReactiveWrapper(); -export function autocomplete({ - container, - panelContainer = document.body, - render: renderer = defaultRenderer, - panelPlacement = 'input-wrapper-width', - classNames = {}, - getEnvironmentProps = ({ props }) => props, - getFormProps = ({ props }) => props, - getInputProps = ({ props }) => props, - getItemProps = ({ props }) => props, - getLabelProps = ({ props }) => props, - getListProps = ({ props }) => props, - getPanelProps = ({ props }) => props, - getRootProps = ({ props }) => props, - ...props -}: AutocompleteOptions): AutocompleteApi { - const { runEffect, cleanupEffects } = createEffectWrapper(); + const optionsRef = createRef(options); const onStateChangeRef = createRef< - | ((params: { - state: AutocompleteState; - prevState: AutocompleteState; - }) => void) - | undefined + AutocompleteOptions['onStateChange'] >(undefined); - const autocomplete = createAutocomplete({ - ...props, - onStateChange(options) { - onStateChangeRef.current?.(options as any); - props.onStateChange?.(options); - }, - }); - const initialState: AutocompleteState = { + const props = reactive(() => getDefaultOptions(optionsRef.current)); + const autocomplete = reactive(() => + createAutocomplete({ + ...props.current.core, + onStateChange(options) { + onStateChangeRef.current?.(options as any); + props.current.core.onStateChange?.(options as any); + }, + }) + ); + const lastStateRef = createRef>({ collections: [], completion: null, context: {}, @@ -62,52 +47,55 @@ export function autocomplete({ query: '', selectedItemId: null, status: 'idle', - ...props.initialState, - }; + ...props.current.core.initialState, + }); const propGetters: AutocompletePropGetters = { - getEnvironmentProps, - getFormProps, - getInputProps, - getItemProps, - getLabelProps, - getListProps, - getPanelProps, - getRootProps, + getEnvironmentProps: props.current.renderer.getEnvironmentProps, + getFormProps: props.current.renderer.getFormProps, + getInputProps: props.current.renderer.getInputProps, + getItemProps: props.current.renderer.getItemProps, + getLabelProps: props.current.renderer.getLabelProps, + getListProps: props.current.renderer.getListProps, + getPanelProps: props.current.renderer.getPanelProps, + getRootProps: props.current.renderer.getRootProps, }; const autocompleteScopeApi: AutocompleteScopeApi = { - setSelectedItemId: autocomplete.setSelectedItemId, - setQuery: autocomplete.setQuery, - setCollections: autocomplete.setCollections, - setIsOpen: autocomplete.setIsOpen, - setStatus: autocomplete.setStatus, - setContext: autocomplete.setContext, - refresh: autocomplete.refresh, + setSelectedItemId: autocomplete.current.setSelectedItemId, + setQuery: autocomplete.current.setQuery, + setCollections: autocomplete.current.setCollections, + setIsOpen: autocomplete.current.setIsOpen, + setStatus: autocomplete.current.setStatus, + setContext: autocomplete.current.setContext, + refresh: autocomplete.current.refresh, }; - const dom = createAutocompleteDom({ - state: initialState, - autocomplete, - classNames, - propGetters, - autocompleteScopeApi, - }); + + const dom = reactive(() => + createAutocompleteDom({ + state: lastStateRef.current, + autocomplete: autocomplete.current, + classNames: props.current.renderer.classNames, + propGetters, + autocompleteScopeApi, + }) + ); function setPanelPosition() { - setProperties(dom.panel, { + setProperties(dom.current.panel, { style: getPanelPositionStyle({ - panelPlacement, - container: dom.root, - form: dom.form, - environment: props.environment, + panelPlacement: props.current.renderer.panelPlacement, + container: dom.current.root, + form: dom.current.form, + environment: props.current.core.environment, }), }); } runEffect(() => { - const environmentProps = autocomplete.getEnvironmentProps({ - formElement: dom.form, - panelElement: dom.panel, - inputElement: dom.input, + const environmentProps = autocomplete.current.getEnvironmentProps({ + formElement: dom.current.form, + panelElement: dom.current.panel, + inputElement: dom.current.input, }); setProperties(window as any, environmentProps); @@ -126,45 +114,54 @@ export function autocomplete({ }); runEffect(() => { - const panelRoot = getHTMLElement(panelContainer); - render(renderer, { - state: initialState, - autocomplete, + const containerElement = getHTMLElement(props.current.renderer.container); + invariant( + containerElement.tagName !== 'INPUT', + 'The `container` option does not support `input` elements. You need to change the container to a `div`.' + ); + containerElement.appendChild(dom.current.root); + + return () => { + containerElement.removeChild(dom.current.root); + }; + }); + + runEffect(() => { + const panelElement = getHTMLElement(props.current.renderer.panelContainer); + render(props.current.renderer.render, { + state: lastStateRef.current, + autocomplete: autocomplete.current, propGetters, - dom, - classNames, - panelRoot, + dom: dom.current, + classNames: props.current.renderer.classNames, + panelRoot: panelElement, autocompleteScopeApi, }); - return () => {}; + return () => { + if (panelElement.contains(dom.current.panel)) { + panelElement.removeChild(dom.current.panel); + } + }; }); runEffect(() => { - const panelRoot = getHTMLElement(panelContainer); - const unmountRef = createRef<(() => void) | undefined>(undefined); - // This batches state changes to limit DOM mutations. - // Every time we call a setter in `autocomplete-core` (e.g., in `onInput`), - // the core `onStateChange` function is called. - // We don't need to be notified of all these state changes to render. - // As an example: - // - without debouncing: "iphone case" query → 85 renders - // - with debouncing: "iphone case" query → 12 renders - const debouncedOnStateChange = debounce<{ + const debouncedRender = debounce<{ state: AutocompleteState; }>(({ state }) => { - unmountRef.current = render(renderer, { + lastStateRef.current = state; + render(props.current.renderer.render, { state, - autocomplete, + autocomplete: autocomplete.current, propGetters, - dom, - classNames, - panelRoot, + dom: dom.current, + classNames: props.current.renderer.classNames, + panelRoot: getHTMLElement(props.current.renderer.panelContainer), autocompleteScopeApi, }); }, 0); - onStateChangeRef.current = ({ prevState, state }) => { + onStateChangeRef.current = ({ state, prevState }) => { // The outer DOM might have changed since the last time the panel was // positioned. The layout might have shifted vertically for instance. // It's therefore safer to re-calculate the panel position before opening @@ -173,33 +170,18 @@ export function autocomplete({ setPanelPosition(); } - return debouncedOnStateChange({ state }); + debouncedRender({ state }); }; return () => { - unmountRef.current?.(); onStateChangeRef.current = undefined; }; }); - runEffect(() => { - const containerElement = getHTMLElement(container); - invariant( - containerElement.tagName !== 'INPUT', - 'The `container` option does not support `input` elements. You need to change the container to a `div`.' - ); - containerElement.appendChild(dom.root); - - return () => { - containerElement.removeChild(dom.root); - }; - }); - runEffect(() => { const onResize = debounce(() => { setPanelPosition(); - }, 100); - + }, 20); window.addEventListener('resize', onResize); return () => { @@ -207,14 +189,45 @@ export function autocomplete({ }; }); - requestAnimationFrame(() => { - setPanelPosition(); + runEffect(() => { + requestAnimationFrame(setPanelPosition); + + return () => {}; }); + function destroy() { + cleanupEffects(); + } + + function update(updatedOptions: Partial> = {}) { + cleanupEffects(); + + optionsRef.current = mergeDeep( + props.current.renderer, + props.current.core, + { initialState: lastStateRef.current }, + updatedOptions + ); + + runReactives(); + runEffects(); + + autocomplete.current.refresh().then(() => { + render(props.current.renderer.render, { + state: lastStateRef.current, + autocomplete: autocomplete.current, + propGetters, + dom: dom.current, + classNames: props.current.renderer.classNames, + panelRoot: getHTMLElement(props.current.renderer.panelContainer), + autocompleteScopeApi, + }); + }); + } + return { ...autocompleteScopeApi, - destroy() { - cleanupEffects(); - }, + update, + destroy, }; } diff --git a/packages/autocomplete-js/src/createEffectWrapper.ts b/packages/autocomplete-js/src/createEffectWrapper.ts index 775ad1c8f..7e40cc0a3 100644 --- a/packages/autocomplete-js/src/createEffectWrapper.ts +++ b/packages/autocomplete-js/src/createEffectWrapper.ts @@ -4,12 +4,15 @@ type CleanupFn = () => void; type EffectWrapper = { runEffect(fn: EffectFn): void; cleanupEffects(): void; + runEffects(): void; }; export function createEffectWrapper(): EffectWrapper { + let effects: EffectFn[] = []; let cleanups: CleanupFn[] = []; function runEffect(fn: EffectFn) { + effects.push(fn); const effectCleanup = fn(); cleanups.push(effectCleanup); } @@ -23,5 +26,12 @@ export function createEffectWrapper(): EffectWrapper { cleanup(); }); }, + runEffects() { + const currentEffects = effects; + effects = []; + currentEffects.forEach((effect) => { + runEffect(effect); + }); + }, }; } diff --git a/packages/autocomplete-js/src/createReactiveWrapper.ts b/packages/autocomplete-js/src/createReactiveWrapper.ts new file mode 100644 index 000000000..2998fb519 --- /dev/null +++ b/packages/autocomplete-js/src/createReactiveWrapper.ts @@ -0,0 +1,44 @@ +type ReactiveValue = () => TValue; +export type Reactive = { + current: TValue; + /** + * @private + */ + _fn: ReactiveValue; + /** + * @private + */ + _ref: { + current: TValue; + }; +}; + +export function createReactiveWrapper() { + const reactives: Array> = []; + + return { + reactive(value: ReactiveValue) { + const reactive: Reactive = { + _fn: value, + _ref: { current: value() }, + get current() { + return this._ref.current; + }, + set current(value) { + this._ref.current = value; + }, + }; + + reactives.push(reactive); + + value(); + + return reactive; + }, + runReactives() { + reactives.forEach((value) => { + value._ref.current = value._fn(); + }); + }, + }; +} diff --git a/packages/autocomplete-js/src/defaultRenderer.ts b/packages/autocomplete-js/src/defaultRenderer.ts new file mode 100644 index 000000000..a22d0620d --- /dev/null +++ b/packages/autocomplete-js/src/defaultRenderer.ts @@ -0,0 +1,5 @@ +export function defaultRenderer({ root, sections }) { + for (const section of sections) { + root.appendChild(section); + } +} diff --git a/packages/autocomplete-js/src/getDefaultOptions.ts b/packages/autocomplete-js/src/getDefaultOptions.ts new file mode 100644 index 000000000..70023dd6b --- /dev/null +++ b/packages/autocomplete-js/src/getDefaultOptions.ts @@ -0,0 +1,45 @@ +import { BaseItem } from '@algolia/autocomplete-core'; + +import { defaultRenderer } from './defaultRenderer'; +import { AutocompleteOptions } from './types'; + +export function getDefaultOptions( + options: AutocompleteOptions +) { + const { + container, + panelContainer, + render, + panelPlacement, + classNames, + getEnvironmentProps, + getFormProps, + getInputProps, + getItemProps, + getLabelProps, + getListProps, + getPanelProps, + getRootProps, + ...core + } = options; + const renderer = { + container, + panelContainer: panelContainer ?? document.body, + render: render ?? defaultRenderer, + panelPlacement: panelPlacement ?? 'input-wrapper-width', + classNames: classNames ?? {}, + getEnvironmentProps: getEnvironmentProps ?? (({ props }) => props), + getFormProps: getFormProps ?? (({ props }) => props), + getInputProps: getInputProps ?? (({ props }) => props), + getItemProps: getItemProps ?? (({ props }) => props), + getLabelProps: getLabelProps ?? (({ props }) => props), + getListProps: getListProps ?? (({ props }) => props), + getPanelProps: getPanelProps ?? (({ props }) => props), + getRootProps: getRootProps ?? (({ props }) => props), + }; + + return { + renderer, + core, + }; +} diff --git a/packages/autocomplete-js/src/render.ts b/packages/autocomplete-js/src/render.ts index 371bb4336..7c532266c 100644 --- a/packages/autocomplete-js/src/render.ts +++ b/packages/autocomplete-js/src/render.ts @@ -43,7 +43,7 @@ export function render( dom, autocompleteScopeApi, }: RenderProps -): () => void { +): void { setPropertiesWithoutEvents( dom.root, propGetters.getRootProps({ @@ -72,7 +72,7 @@ export function render( panelRoot.removeChild(dom.panel); } - return () => {}; + return; } // We add the panel element to the DOM when it's not yet appended and that the @@ -157,8 +157,4 @@ export function render( dom.panel.appendChild(panelLayoutElement); renderer({ root: panelLayoutElement, sections, state }); - - return () => { - panelRoot.removeChild(dom.panel); - }; } diff --git a/packages/autocomplete-js/src/types/AutocompleteApi.ts b/packages/autocomplete-js/src/types/AutocompleteApi.ts index 2e4af9261..89c929533 100644 --- a/packages/autocomplete-js/src/types/AutocompleteApi.ts +++ b/packages/autocomplete-js/src/types/AutocompleteApi.ts @@ -3,8 +3,14 @@ import { BaseItem, } from '@algolia/autocomplete-core'; +import { AutocompleteOptions } from './AutocompleteOptions'; + export interface AutocompleteApi extends AutocompleteCoreScopeApi { + /** + * Updates the Autocomplete experience. + */ + update(updatedOptions: Partial>): void; /** * Cleans up the DOM mutations and event listeners. */ diff --git a/packages/autocomplete-js/src/types/AutocompleteOptions.ts b/packages/autocomplete-js/src/types/AutocompleteOptions.ts index fbb515f68..4f4e412f9 100644 --- a/packages/autocomplete-js/src/types/AutocompleteOptions.ts +++ b/packages/autocomplete-js/src/types/AutocompleteOptions.ts @@ -65,4 +65,8 @@ export interface AutocompleteOptions */ render?: AutocompleteRenderer; initialState?: Partial>; + onStateChange?(props: { + state: AutocompleteState; + prevState: AutocompleteState; + }): void; } diff --git a/packages/autocomplete-js/src/utils/index.ts b/packages/autocomplete-js/src/utils/index.ts index 453214642..8823bf8a5 100644 --- a/packages/autocomplete-js/src/utils/index.ts +++ b/packages/autocomplete-js/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './concatClassNames'; export * from './getHTMLElement'; +export * from './mergeDeep'; export * from './setProperties'; diff --git a/packages/autocomplete-js/src/utils/mergeDeep.ts b/packages/autocomplete-js/src/utils/mergeDeep.ts new file mode 100644 index 000000000..f19cb3da7 --- /dev/null +++ b/packages/autocomplete-js/src/utils/mergeDeep.ts @@ -0,0 +1,20 @@ +const isObject = (value: unknown) => value && typeof value === 'object'; + +export function mergeDeep(...objects: any[]) { + return objects.reduce((acc, current) => { + Object.keys(current).forEach((key) => { + const accValue = acc[key]; + const currentValue = current[key]; + + if (Array.isArray(accValue) && Array.isArray(currentValue)) { + acc[key] = accValue.concat(...currentValue); + } else if (isObject(accValue) && isObject(currentValue)) { + acc[key] = mergeDeep(accValue, currentValue); + } else { + acc[key] = currentValue; + } + }); + + return acc; + }, {}); +}