Skip to content

Commit

Permalink
feat: preserve shared state on unmount with a future flag (#5123)
Browse files Browse the repository at this point in the history

Co-authored-by: Dhaya <154633+dhayab@users.noreply.github.com>
  • Loading branch information
Haroenv and dhayab committed Oct 9, 2023
1 parent 85eac81 commit 2258d89
Show file tree
Hide file tree
Showing 8 changed files with 239 additions and 10 deletions.
2 changes: 1 addition & 1 deletion bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.production.min.js",
"maxSize": "74.50 kB"
"maxSize": "74.75 kB"
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.development.js",
Expand Down
36 changes: 36 additions & 0 deletions packages/instantsearch.js/src/lib/InstantSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,27 @@ export type InstantSearchOptions<
* @deprecated This property will be still supported in 4.x releases, but not further. It is replaced by the `insights` middleware. For more information, visit https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/how-to/send-click-and-conversion-events-with-instantsearch/js/
*/
insightsClient?: AlgoliaInsightsClient;
future?: {
/**
* Changes the way `dispose` is used in InstantSearch lifecycle.
*
* If `false` (by default), each widget unmounting will remove its state as well, even if there are multiple widgets reading that UI State.
*
* If `true`, each widget unmounting will only remove its own state if it's the last of its type. This allows for dynamically adding and removing widgets without losing their state.
*
* @default false
*/
// @MAJOR: Remove legacy behaviour
preserveSharedStateOnUnmount?: boolean;
};
};

export type InstantSearchStatus = 'idle' | 'loading' | 'stalled' | 'error';

export const INSTANTSEARCH_FUTURE_DEFAULTS: Required<
InstantSearchOptions['future']
> = { preserveSharedStateOnUnmount: false };

/**
* The actual implementation of the InstantSearch. This is
* created using the `instantsearch` factory function.
Expand All @@ -176,6 +193,7 @@ class InstantSearch<
public insightsClient: AlgoliaInsightsClient | null;
public onStateChange: InstantSearchOptions<TUiState>['onStateChange'] | null =
null;
public future: NonNullable<InstantSearchOptions<TUiState>['future']>;
public helper: AlgoliaSearchHelper | null;
public mainHelper: AlgoliaSearchHelper | null;
public mainIndex: IndexWidget;
Expand Down Expand Up @@ -235,6 +253,10 @@ Use \`InstantSearch.status === "stalled"\` instead.`
searchClient = null,
insightsClient = null,
onStateChange = null,
future = {
...INSTANTSEARCH_FUTURE_DEFAULTS,
...(options.future || {}),
},
} = options;

if (searchClient === null) {
Expand Down Expand Up @@ -283,7 +305,21 @@ See ${createDocumentationLink({
})}`
);

if (__DEV__ && options.future?.preserveSharedStateOnUnmount === undefined) {
// eslint-disable-next-line no-console
console.info(`Starting from the next major version, InstantSearch will change how widgets state is preserved when they are removed. InstantSearch will keep the state of unmounted widgets to be usable by other widgets with the same attribute.
We recommend setting \`future.preserveSharedStateOnUnmount\` to true to adopt this change today.
To stay with the current behaviour and remove this warning, set the option to false.
See documentation: ${createDocumentationLink({
name: 'instantsearch',
})}#widget-param-future
`);
}

this.client = searchClient;
this.future = future;
this.insightsClient = insightsClient;
this.indexName = indexName;
this.helper = null;
Expand Down
151 changes: 151 additions & 0 deletions packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,157 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge
).toHaveBeenCalledTimes(2);
});

it('cleans shared refinements when `preserveSharedStateOnUnmount` is unset', () => {
const instance = index({ indexName: 'indexName' });
const instantSearchInstance = createInstantSearch();

const refinementList1 = virtualRefinementList({
attribute: 'brand',
});

const refinementList2 = virtualRefinementList({
attribute: 'brand',
});

instance.addWidgets([refinementList1, refinementList2]);

instance.init(
createIndexInitOptions({
instantSearchInstance,
parent: null,
})
);

// Simulate a state change
instance.getHelper()!.addDisjunctiveFacetRefinement('brand', 'Apple');

expect(instance.getHelper()!.state).toEqual(
new SearchParameters({
index: 'indexName',
maxValuesPerFacet: 10,
disjunctiveFacets: ['brand'],
disjunctiveFacetsRefinements: {
brand: ['Apple'],
},
})
);

instance.removeWidgets([refinementList2]);

expect(instance.getHelper()!.state).toEqual(
new SearchParameters({
index: 'indexName',
maxValuesPerFacet: 10,
disjunctiveFacets: ['brand'],
disjunctiveFacetsRefinements: {
brand: [],
},
})
);
});

it('cleans shared refinements when `preserveSharedStateOnUnmount` is false', () => {
const instance = index({ indexName: 'indexName' });
const instantSearchInstance = createInstantSearch({
future: { preserveSharedStateOnUnmount: false },
});

const refinementList1 = virtualRefinementList({
attribute: 'brand',
});

const refinementList2 = virtualRefinementList({
attribute: 'brand',
});

instance.addWidgets([refinementList1, refinementList2]);

instance.init(
createIndexInitOptions({
instantSearchInstance,
parent: null,
})
);

// Simulate a state change
instance.getHelper()!.addDisjunctiveFacetRefinement('brand', 'Apple');

expect(instance.getHelper()!.state).toEqual(
new SearchParameters({
index: 'indexName',
maxValuesPerFacet: 10,
disjunctiveFacets: ['brand'],
disjunctiveFacetsRefinements: {
brand: ['Apple'],
},
})
);

instance.removeWidgets([refinementList2]);

expect(instance.getHelper()!.state).toEqual(
new SearchParameters({
index: 'indexName',
maxValuesPerFacet: 10,
disjunctiveFacets: ['brand'],
disjunctiveFacetsRefinements: {
brand: [],
},
})
);
});

it('preserves shared refinements when `preserveSharedStateOnUnmount` is true', () => {
const instance = index({ indexName: 'indexName' });
const instantSearchInstance = createInstantSearch({
future: { preserveSharedStateOnUnmount: true },
});

const refinementList1 = virtualRefinementList({
attribute: 'brand',
});

const refinementList2 = virtualRefinementList({
attribute: 'brand',
});

instance.addWidgets([refinementList1, refinementList2]);

instance.init(
createIndexInitOptions({
instantSearchInstance,
parent: null,
})
);

// Simulate a state change
instance.getHelper()!.addDisjunctiveFacetRefinement('brand', 'Apple');

expect(instance.getHelper()!.state).toEqual(
new SearchParameters({
index: 'indexName',
maxValuesPerFacet: 10,
disjunctiveFacets: ['brand'],
disjunctiveFacetsRefinements: {
brand: ['Apple'],
},
})
);

instance.removeWidgets([refinementList2]);

expect(instance.getHelper()!.state).toEqual(
new SearchParameters({
index: 'indexName',
maxValuesPerFacet: 10,
disjunctiveFacets: ['brand'],
disjunctiveFacetsRefinements: {
brand: ['Apple'],
},
})
);
});

it('calls `dispose` on the removed widgets', () => {
const instance = index({ indexName: 'indexName' });
const widgets = [
Expand Down
29 changes: 21 additions & 8 deletions packages/instantsearch.js/src/widgets/index/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => {
);

if (localInstantSearchInstance && Boolean(widgets.length)) {
const nextState = widgets.reduce((state, widget) => {
const cleanedState = widgets.reduce((state, widget) => {
// the `dispose` method exists at this point we already assert it
const next = widget.dispose!({
helper: helper!,
Expand All @@ -400,17 +400,30 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => {
return next || state;
}, helper!.state);

const newState = localInstantSearchInstance.future
.preserveSharedStateOnUnmount
? getLocalWidgetsSearchParameters(localWidgets, {
uiState: localUiState,
initialSearchParameters: new algoliasearchHelper.SearchParameters(
{
index: this.getIndexName(),
}
),
})
: getLocalWidgetsSearchParameters(localWidgets, {
uiState: getLocalWidgetsUiState(localWidgets, {
searchParameters: cleanedState,
helper: helper!,
}),
initialSearchParameters: cleanedState,
});

localUiState = getLocalWidgetsUiState(localWidgets, {
searchParameters: nextState,
searchParameters: newState,
helper: helper!,
});

helper!.setState(
getLocalWidgetsSearchParameters(localWidgets, {
uiState: localUiState,
initialSearchParameters: nextState,
})
);
helper!.setState(newState);

if (localWidgets.length) {
localInstantSearchInstance.scheduleSearch();
Expand Down
5 changes: 5 additions & 0 deletions packages/instantsearch.js/test/createInstantSearch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createSearchClient } from '@instantsearch/mocks';
import algoliasearchHelper from 'algoliasearch-helper';

import { INSTANTSEARCH_FUTURE_DEFAULTS } from '../src/lib/InstantSearch';
import { defer } from '../src/lib/utils';
import index from '../src/widgets/index/index';

Expand Down Expand Up @@ -69,6 +70,10 @@ export const createInstantSearch = (
emit: jest.fn(),
listenerCount: jest.fn(),
sendEventToInsights: jest.fn(),
future: {
...INSTANTSEARCH_FUTURE_DEFAULTS,
...(args.future || {}),
},
...args,
};
};
13 changes: 12 additions & 1 deletion packages/react-instantsearch-core/src/lib/useInstantSearchApi.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import InstantSearch from 'instantsearch.js/es/lib/InstantSearch';
import InstantSearch, {
INSTANTSEARCH_FUTURE_DEFAULTS,
} from 'instantsearch.js/es/lib/InstantSearch';
import { useCallback, useRef, version as ReactVersion } from 'react';
import { useSyncExternalStore } from 'use-sync-external-store/shim';

import version from '../version';

import { dequal } from './dequal';
import { useForceUpdate } from './useForceUpdate';
import { useInstantSearchServerContext } from './useInstantSearchServerContext';
import { useInstantSearchSSRContext } from './useInstantSearchSSRContext';
Expand Down Expand Up @@ -174,6 +177,14 @@ export function useInstantSearchApi<TUiState extends UiState, TRouteState>(
prevPropsRef.current = props;
}

if (!dequal(prevProps.future, props.future)) {
search.future = {
...INSTANTSEARCH_FUTURE_DEFAULTS,
...props.future,
};
prevPropsRef.current = props;
}

// Updating the `routing` prop is not supported because InstantSearch.js
// doesn't let us change it. This might not be a problem though, because `routing`
// shouldn't need to be dynamic.
Expand Down
5 changes: 5 additions & 0 deletions packages/vue-instantsearch/src/components/InstantSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ export default createInstantSearchComponent({
type: Array,
default: null,
},
future: {
type: Object,
default: undefined,
},
},
data() {
return {
Expand All @@ -98,6 +102,7 @@ export default createInstantSearchComponent({
searchFunction: this.searchFunction,
onStateChange: this.onStateChange,
initialUiState: this.initialUiState,
future: this.future,
}),
};
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { INSTANTSEARCH_FUTURE_DEFAULTS } from 'instantsearch.js/es/lib/InstantSearch';

import { version } from '../../package.json'; // rollup does pick only what needed from json
import { createSuitMixin } from '../mixins/suit';

Expand Down Expand Up @@ -64,6 +66,12 @@ export const createInstantSearchComponent = (component) =>
});
},
},
future(future) {
this.instantSearchInstance.future = Object.assign(
INSTANTSEARCH_FUTURE_DEFAULTS,
future
);
},
},
created() {
const searchClient = this.instantSearchInstance.client;
Expand Down

0 comments on commit 2258d89

Please sign in to comment.