Skip to content

Commit

Permalink
fix to reduce rerenders
Browse files Browse the repository at this point in the history
  • Loading branch information
walterra committed May 7, 2024
1 parent b13bec0 commit 4acad8f
Show file tree
Hide file tree
Showing 3 changed files with 57 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,8 @@ export const initialState: StreamState = {

export function streamReducer(
state: StreamState,
action: AiopsLogRateAnalysisApiAction | AiopsLogRateAnalysisApiAction[]
action: AiopsLogRateAnalysisApiAction
): StreamState {
if (Array.isArray(action)) {
return action.reduce(streamReducer, state);
}

switch (action.type) {
case API_ACTION_NAME.ADD_SIGNIFICANT_ITEMS:
return { ...state, significantItems: [...state.significantItems, ...action.payload] };
Expand Down
12 changes: 3 additions & 9 deletions x-pack/packages/ml/response_stream/client/string_reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,14 @@

import type { Reducer, ReducerAction, ReducerState } from 'react';

type StringReducerPayload = string | string[] | undefined;
type StringReducerPayload = string | undefined;
export type StringReducer = Reducer<string, StringReducerPayload>;

/**
* The `stringReducer` is provided to handle plain string based streams with `streamFactory()`.
*
* @param state - The current state, being the string fetched so far.
* @param payload — The state update can be a plain string, an array of strings or `undefined`.
* * An array of strings will be joined without a delimiter and added to the current string.
* In combination with `useFetchStream`'s buffering this allows to do bulk updates
* within the reducer without triggering a React/DOM update on every stream chunk.
* * `undefined` can be used to reset the state to an empty string, for example, when a
* UI has the option to trigger a refetch of a stream.
*
* @param payload — The state update can be a plain string to be added or `undefined` to reset the state.
* @returns The updated state, a string that combines the previous string and the payload.
*/
export function stringReducer(
Expand All @@ -31,5 +25,5 @@ export function stringReducer(
return '';
}

return `${state}${Array.isArray(payload) ? payload.join('') : payload}`;
return `${state}${payload}`;
}
69 changes: 53 additions & 16 deletions x-pack/packages/ml/response_stream/client/use_fetch_stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@

import {
useEffect,
useReducer,
useRef,
useState,
type Reducer,
type ReducerAction,
type ReducerState,
type ReducerAction,
} from 'react';
import useThrottle from 'react-use/lib/useThrottle';

import type { HttpSetup, HttpFetchOptions } from '@kbn/core/public';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';

import { fetchStream } from './fetch_stream';
import { stringReducer, type StringReducer } from './string_reducer';

const DATA_THROTTLE_MS = 100;

// This pattern with a dual ternary allows us to default to StringReducer
// and if a custom reducer is supplied fall back to that one instead.
// The complexity in here allows us to create a simpler API surface where
Expand Down Expand Up @@ -57,6 +57,7 @@ function isReducerOptions<T>(arg: unknown): arg is CustomReducer<T> {
* @param apiVersion Optional API version.
* @param body Optional API request body.
* @param customReducer Optional custom reducer and initial state.
* @param headers Optional headers.
* @returns An object with streaming data and methods to act on the stream.
*/
export function useFetchStream<B extends object, R extends Reducer<any, any>>(
Expand All @@ -75,11 +76,41 @@ export function useFetchStream<B extends object, R extends Reducer<any, any>>(
? customReducer
: ({ reducer: stringReducer, initialState: '' } as FetchStreamCustomReducer<R>);

const [data, dispatch] = useReducer(
reducerWithFallback.reducer,
reducerWithFallback.initialState
);
const dataThrottled = useThrottle(data, 100);
// We used `useReducer` in previous iterations of this hook, but it caused
// a lot of unnecessary re-renders even in combination with `useThrottle`.
// We're now using `dataRef` to allow updates outside of the render cycle.
// When the stream is running, we'll update `data` with the `dataRef` value
// periodically.
const [data, setData] = useState(reducerWithFallback.initialState);
const dataRef = useRef(reducerWithFallback.initialState);

// This effect is used to throttle the data updates while the stream is running.
// It will update the `data` state with the current `dataRef` value every 100ms.
useEffect(() => {
// We cannot check against `isRunning` in the `setTimeout` callback, because
// we would check against a stale value. Instead, we use a mutable
// object to keep track of the current state of the effect.
const effectState = { isActive: true };

if (isRunning) {
setData(dataRef.current);

function updateData() {
setTimeout(() => {
setData(dataRef.current);
if (effectState.isActive) {
updateData();
}
}, DATA_THROTTLE_MS);
}

updateData();
}

return () => {
effectState.isActive = false;
};
}, [isRunning]);

const abortCtrl = useRef(new AbortController());

Expand All @@ -99,7 +130,7 @@ export function useFetchStream<B extends object, R extends Reducer<any, any>>(

abortCtrl.current = new AbortController();

for await (const [fetchStreamError, actions] of fetchStream<B, CustomReducer<R>>(
for await (const [fetchStreamError, action] of fetchStream<B, CustomReducer<R>>(
http,
endpoint,
apiVersion,
Expand All @@ -110,16 +141,22 @@ export function useFetchStream<B extends object, R extends Reducer<any, any>>(
)) {
if (fetchStreamError !== null) {
addError(fetchStreamError);
} else if (Array.isArray(actions) && actions.length > 0) {
for (const action of actions) {
dispatch(action as ReducerAction<CustomReducer<R>>);
}
} else if (action) {
dataRef.current = reducerWithFallback.reducer(dataRef.current, action) as ReducerState<
CustomReducer<R>
>;
}
}

setIsRunning(false);
};

const dispatch = (action: ReducerAction<FetchStreamCustomReducer<R>['reducer']>) => {
dataRef.current = reducerWithFallback.reducer(dataRef.current, action) as ReducerState<
CustomReducer<R>
>;
};

const cancel = () => {
abortCtrl.current.abort();
setIsCancelled(true);
Expand All @@ -133,10 +170,10 @@ export function useFetchStream<B extends object, R extends Reducer<any, any>>(

return {
cancel,
// To avoid a race condition where the stream already ended but `useThrottle` would
// yet have to trigger another update within the throttling interval, we'll return
// To avoid a race condition where the stream already ended but the throttling would
// yet have to trigger another update within the interval, we'll return
// the unthrottled data once the stream is complete.
data: isRunning ? dataThrottled : data,
data: isRunning ? data : dataRef.current,
dispatch,
errors,
isCancelled,
Expand Down

0 comments on commit 4acad8f

Please sign in to comment.