Skip to content

Commit 921788c

Browse files
feat(js): introduce Update API
1 parent f1e8de4 commit 921788c

12 files changed

Lines changed: 269 additions & 125 deletions

File tree

bundlesize.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
},
77
{
88
"path": "packages/autocomplete-js/dist/umd/index.production.js",
9-
"maxSize": "9.25 kB"
9+
"maxSize": "9.75 kB"
1010
},
1111
{
1212
"path": "packages/autocomplete-preset-algolia/dist/umd/index.production.js",

packages/autocomplete-core/src/types/AutocompleteOptions.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import {
1010
} from './AutocompleteSource';
1111
import { AutocompleteState } from './AutocompleteState';
1212

13-
interface OnSubmitParams<TItem extends BaseItem>
13+
export interface OnSubmitParams<TItem extends BaseItem>
1414
extends AutocompleteScopeApi<TItem> {
1515
state: AutocompleteState<TItem>;
1616
event: any;
1717
}
1818

19-
type OnResetParams<TItem extends BaseItem> = OnSubmitParams<TItem>;
19+
export type OnResetParams<TItem extends BaseItem> = OnSubmitParams<TItem>;
2020

21-
interface OnInputParams<TItem extends BaseItem>
21+
export interface OnInputParams<TItem extends BaseItem>
2222
extends AutocompleteScopeApi<TItem> {
2323
query: string;
2424
state: AutocompleteState<TItem>;

packages/autocomplete-js/src/autocomplete.ts

Lines changed: 128 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { createRef, debounce, invariant } from '@algolia/autocomplete-shared';
77

88
import { createAutocompleteDom } from './createAutocompleteDom';
99
import { createEffectWrapper } from './createEffectWrapper';
10+
import { createReactiveWrapper } from './createReactiveWrapper';
11+
import { getDefaultOptions } from './getDefaultOptions';
1012
import { getPanelPositionStyle } from './getPanelPositionStyle';
1113
import { render } from './render';
1214
import {
@@ -15,99 +17,85 @@ import {
1517
AutocompletePropGetters,
1618
AutocompleteState,
1719
} from './types';
18-
import { getHTMLElement, setProperties } from './utils';
20+
import { getHTMLElement, mergeDeep, setProperties } from './utils';
1921

20-
function defaultRenderer({ root, sections }) {
21-
for (const section of sections) {
22-
root.appendChild(section);
23-
}
24-
}
22+
export function autocomplete<TItem extends BaseItem>(
23+
options: AutocompleteOptions<TItem>
24+
): AutocompleteApi<TItem> {
25+
const { runEffect, cleanupEffects, runEffects } = createEffectWrapper();
26+
const { reactive, runReactives } = createReactiveWrapper();
2527

26-
export function autocomplete<TItem extends BaseItem>({
27-
container,
28-
panelContainer = document.body,
29-
render: renderer = defaultRenderer,
30-
panelPlacement = 'input-wrapper-width',
31-
classNames = {},
32-
getEnvironmentProps = ({ props }) => props,
33-
getFormProps = ({ props }) => props,
34-
getInputProps = ({ props }) => props,
35-
getItemProps = ({ props }) => props,
36-
getLabelProps = ({ props }) => props,
37-
getListProps = ({ props }) => props,
38-
getPanelProps = ({ props }) => props,
39-
getRootProps = ({ props }) => props,
40-
...props
41-
}: AutocompleteOptions<TItem>): AutocompleteApi<TItem> {
42-
const { runEffect, cleanupEffects } = createEffectWrapper();
28+
const optionsRef = createRef(options);
4329
const onStateChangeRef = createRef<
44-
| ((params: {
45-
state: AutocompleteState<TItem>;
46-
prevState: AutocompleteState<TItem>;
47-
}) => void)
48-
| undefined
30+
AutocompleteOptions<TItem>['onStateChange']
4931
>(undefined);
50-
const autocomplete = createAutocomplete<TItem>({
51-
...props,
52-
onStateChange(options) {
53-
onStateChangeRef.current?.(options as any);
54-
props.onStateChange?.(options);
55-
},
56-
});
57-
const initialState: AutocompleteState<TItem> = {
32+
const props = reactive(() => getDefaultOptions(optionsRef.current));
33+
const autocomplete = reactive(() =>
34+
createAutocomplete<TItem>({
35+
...props.current.core,
36+
onStateChange(options) {
37+
onStateChangeRef.current?.(options as any);
38+
props.current.core.onStateChange?.(options as any);
39+
},
40+
})
41+
);
42+
const lastStateRef = createRef<AutocompleteState<TItem>>({
5843
collections: [],
5944
completion: null,
6045
context: {},
6146
isOpen: false,
6247
query: '',
6348
selectedItemId: null,
6449
status: 'idle',
65-
...props.initialState,
66-
};
50+
...props.current.core.initialState,
51+
});
6752

6853
const propGetters: AutocompletePropGetters<TItem> = {
69-
getEnvironmentProps,
70-
getFormProps,
71-
getInputProps,
72-
getItemProps,
73-
getLabelProps,
74-
getListProps,
75-
getPanelProps,
76-
getRootProps,
54+
getEnvironmentProps: props.current.renderer.getEnvironmentProps,
55+
getFormProps: props.current.renderer.getFormProps,
56+
getInputProps: props.current.renderer.getInputProps,
57+
getItemProps: props.current.renderer.getItemProps,
58+
getLabelProps: props.current.renderer.getLabelProps,
59+
getListProps: props.current.renderer.getListProps,
60+
getPanelProps: props.current.renderer.getPanelProps,
61+
getRootProps: props.current.renderer.getRootProps,
7762
};
7863
const autocompleteScopeApi: AutocompleteScopeApi<TItem> = {
79-
setSelectedItemId: autocomplete.setSelectedItemId,
80-
setQuery: autocomplete.setQuery,
81-
setCollections: autocomplete.setCollections,
82-
setIsOpen: autocomplete.setIsOpen,
83-
setStatus: autocomplete.setStatus,
84-
setContext: autocomplete.setContext,
85-
refresh: autocomplete.refresh,
64+
setSelectedItemId: autocomplete.current.setSelectedItemId,
65+
setQuery: autocomplete.current.setQuery,
66+
setCollections: autocomplete.current.setCollections,
67+
setIsOpen: autocomplete.current.setIsOpen,
68+
setStatus: autocomplete.current.setStatus,
69+
setContext: autocomplete.current.setContext,
70+
refresh: autocomplete.current.refresh,
8671
};
87-
const dom = createAutocompleteDom({
88-
state: initialState,
89-
autocomplete,
90-
classNames,
91-
propGetters,
92-
autocompleteScopeApi,
93-
});
72+
73+
const dom = reactive(() =>
74+
createAutocompleteDom({
75+
state: lastStateRef.current,
76+
autocomplete: autocomplete.current,
77+
classNames: props.current.renderer.classNames,
78+
propGetters,
79+
autocompleteScopeApi,
80+
})
81+
);
9482

9583
function setPanelPosition() {
96-
setProperties(dom.panel, {
84+
setProperties(dom.current.panel, {
9785
style: getPanelPositionStyle({
98-
panelPlacement,
99-
container: dom.root,
100-
form: dom.form,
101-
environment: props.environment,
86+
panelPlacement: props.current.renderer.panelPlacement,
87+
container: dom.current.root,
88+
form: dom.current.form,
89+
environment: props.current.core.environment,
10290
}),
10391
});
10492
}
10593

10694
runEffect(() => {
107-
const environmentProps = autocomplete.getEnvironmentProps({
108-
formElement: dom.form,
109-
panelElement: dom.panel,
110-
inputElement: dom.input,
95+
const environmentProps = autocomplete.current.getEnvironmentProps({
96+
formElement: dom.current.form,
97+
panelElement: dom.current.panel,
98+
inputElement: dom.current.input,
11199
});
112100

113101
setProperties(window as any, environmentProps);
@@ -126,45 +114,54 @@ export function autocomplete<TItem extends BaseItem>({
126114
});
127115

128116
runEffect(() => {
129-
const panelRoot = getHTMLElement(panelContainer);
130-
render(renderer, {
131-
state: initialState,
132-
autocomplete,
117+
const containerElement = getHTMLElement(props.current.renderer.container);
118+
invariant(
119+
containerElement.tagName !== 'INPUT',
120+
'The `container` option does not support `input` elements. You need to change the container to a `div`.'
121+
);
122+
containerElement.appendChild(dom.current.root);
123+
124+
return () => {
125+
containerElement.removeChild(dom.current.root);
126+
};
127+
});
128+
129+
runEffect(() => {
130+
const panelElement = getHTMLElement(props.current.renderer.panelContainer);
131+
render(props.current.renderer.render, {
132+
state: lastStateRef.current,
133+
autocomplete: autocomplete.current,
133134
propGetters,
134-
dom,
135-
classNames,
136-
panelRoot,
135+
dom: dom.current,
136+
classNames: props.current.renderer.classNames,
137+
panelRoot: panelElement,
137138
autocompleteScopeApi,
138139
});
139140

140-
return () => {};
141+
return () => {
142+
if (panelElement.contains(dom.current.panel)) {
143+
panelElement.removeChild(dom.current.panel);
144+
}
145+
};
141146
});
142147

143148
runEffect(() => {
144-
const panelRoot = getHTMLElement(panelContainer);
145-
const unmountRef = createRef<(() => void) | undefined>(undefined);
146-
// This batches state changes to limit DOM mutations.
147-
// Every time we call a setter in `autocomplete-core` (e.g., in `onInput`),
148-
// the core `onStateChange` function is called.
149-
// We don't need to be notified of all these state changes to render.
150-
// As an example:
151-
// - without debouncing: "iphone case" query → 85 renders
152-
// - with debouncing: "iphone case" query → 12 renders
153-
const debouncedOnStateChange = debounce<{
149+
const debouncedRender = debounce<{
154150
state: AutocompleteState<TItem>;
155151
}>(({ state }) => {
156-
unmountRef.current = render(renderer, {
152+
lastStateRef.current = state;
153+
render(props.current.renderer.render, {
157154
state,
158-
autocomplete,
155+
autocomplete: autocomplete.current,
159156
propGetters,
160-
dom,
161-
classNames,
162-
panelRoot,
157+
dom: dom.current,
158+
classNames: props.current.renderer.classNames,
159+
panelRoot: getHTMLElement(props.current.renderer.panelContainer),
163160
autocompleteScopeApi,
164161
});
165162
}, 0);
166163

167-
onStateChangeRef.current = ({ prevState, state }) => {
164+
onStateChangeRef.current = ({ state, prevState }) => {
168165
// The outer DOM might have changed since the last time the panel was
169166
// positioned. The layout might have shifted vertically for instance.
170167
// It's therefore safer to re-calculate the panel position before opening
@@ -173,48 +170,64 @@ export function autocomplete<TItem extends BaseItem>({
173170
setPanelPosition();
174171
}
175172

176-
return debouncedOnStateChange({ state });
173+
debouncedRender({ state });
177174
};
178175

179176
return () => {
180-
unmountRef.current?.();
181177
onStateChangeRef.current = undefined;
182178
};
183179
});
184180

185-
runEffect(() => {
186-
const containerElement = getHTMLElement(container);
187-
invariant(
188-
containerElement.tagName !== 'INPUT',
189-
'The `container` option does not support `input` elements. You need to change the container to a `div`.'
190-
);
191-
containerElement.appendChild(dom.root);
192-
193-
return () => {
194-
containerElement.removeChild(dom.root);
195-
};
196-
});
197-
198181
runEffect(() => {
199182
const onResize = debounce<Event>(() => {
200183
setPanelPosition();
201-
}, 100);
202-
184+
}, 20);
203185
window.addEventListener('resize', onResize);
204186

205187
return () => {
206188
window.removeEventListener('resize', onResize);
207189
};
208190
});
209191

210-
requestAnimationFrame(() => {
211-
setPanelPosition();
192+
runEffect(() => {
193+
requestAnimationFrame(setPanelPosition);
194+
195+
return () => {};
212196
});
213197

198+
function destroy() {
199+
cleanupEffects();
200+
}
201+
202+
function update(updatedOptions: Partial<AutocompleteOptions<TItem>> = {}) {
203+
cleanupEffects();
204+
205+
optionsRef.current = mergeDeep(
206+
props.current.renderer,
207+
props.current.core,
208+
{ initialState: lastStateRef.current },
209+
updatedOptions
210+
);
211+
212+
runReactives();
213+
runEffects();
214+
215+
autocomplete.current.refresh().then(() => {
216+
render(props.current.renderer.render, {
217+
state: lastStateRef.current,
218+
autocomplete: autocomplete.current,
219+
propGetters,
220+
dom: dom.current,
221+
classNames: props.current.renderer.classNames,
222+
panelRoot: getHTMLElement(props.current.renderer.panelContainer),
223+
autocompleteScopeApi,
224+
});
225+
});
226+
}
227+
214228
return {
215229
...autocompleteScopeApi,
216-
destroy() {
217-
cleanupEffects();
218-
},
230+
update,
231+
destroy,
219232
};
220233
}

packages/autocomplete-js/src/createEffectWrapper.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ type CleanupFn = () => void;
44
type EffectWrapper = {
55
runEffect(fn: EffectFn): void;
66
cleanupEffects(): void;
7+
runEffects(): void;
78
};
89

910
export function createEffectWrapper(): EffectWrapper {
11+
let effects: EffectFn[] = [];
1012
let cleanups: CleanupFn[] = [];
1113

1214
function runEffect(fn: EffectFn) {
15+
effects.push(fn);
1316
const effectCleanup = fn();
1417
cleanups.push(effectCleanup);
1518
}
@@ -23,5 +26,12 @@ export function createEffectWrapper(): EffectWrapper {
2326
cleanup();
2427
});
2528
},
29+
runEffects() {
30+
const currentEffects = effects;
31+
effects = [];
32+
currentEffects.forEach((effect) => {
33+
runEffect(effect);
34+
});
35+
},
2636
};
2737
}

0 commit comments

Comments
 (0)