Skip to content

Commit

Permalink
Show processing state on repeated calls only after delay
Browse files Browse the repository at this point in the history
  • Loading branch information
ravicious committed Jun 19, 2024
1 parent 4d7c592 commit 6ed1f58
Show file tree
Hide file tree
Showing 3 changed files with 210 additions and 3 deletions.
167 changes: 166 additions & 1 deletion web/packages/shared/hooks/useAsync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@

import { renderHook, act, waitFor } from '@testing-library/react';

import { useAsync, CanceledError } from './useAsync';
import { wait } from 'shared/utils/wait';

import {
useAsync,
CanceledError,
Attempt,
useDelayedRepeatedAttempt,
} from './useAsync';

test('run returns a promise which resolves with the attempt data', async () => {
const returnValue = Symbol();
Expand Down Expand Up @@ -212,3 +219,161 @@ test('error and statusText are set when the callback returns a rejected promise'
// The promise returned from run always succeeds, but any errors are captured as the second arg.
await expect(runPromise).resolves.toEqual([null, expectedError]);
});

describe('useDelayedRepeatedAttempt', () => {
it('does not update attempt status if it resolves before delay', async () => {
let resolve: (symbol: symbol) => void;
const { result } = renderHook(() => {
const [eagerAttempt, run] = useAsync(() => {
// Resolve to a symbol so that successful attempts are not equal to each other.
return new Promise<symbol>(res => {
resolve = res;
});
});
const delayedAttempt = useDelayedRepeatedAttempt(eagerAttempt, 500);

return { run, delayedAttempt };
});

await act(async () => {
const promise = result.current.run();
resolve(Symbol());
await promise;
});

expect(result.current.delayedAttempt.status).toEqual('success');
const oldDelayedAttempt = result.current.delayedAttempt;

act(() => {
// Start promise but do not await it, instead wait for attempt updates. This ensures that we
// catch any state updates caused by the promise being resolved.
result.current.run();
resolve(Symbol());
});

// Wait for delayedAttempt to get updated.
let nextDelayedAttempt: Attempt<symbol>;
await waitFor(
() => {
// result.current always returns the current result. Capture the attempt that's being
// compared within waitFor so that we can check its status outside of the block and be sure
// that it's the same attempt.
nextDelayedAttempt = result.current.delayedAttempt;
expect(nextDelayedAttempt).not.toBe(oldDelayedAttempt);
},
{
onTimeout: error => {
error.message =
'delayedAttempt did not get updated within timeout. ' +
`This might mean that the logic for detecting attempt updates is incorrect.\n${error.message}`;
return error;
},
}
);

// As the promise was resolved before the delay, the attempt status should still be success.
expect(nextDelayedAttempt.status).toEqual('success');
});

it('updates attempt status to processing if it does not resolve before delay', async () => {
let resolve: (symbol: symbol) => void;
const { result } = renderHook(() => {
const [eagerAttempt, run] = useAsync(() => {
// Resolve to a symbol so that successful attempts are not equal to each other.
return new Promise<symbol>(res => {
resolve = res;
});
});
const delayedAttempt = useDelayedRepeatedAttempt(eagerAttempt, 100);

return { run, delayedAttempt };
});

await act(async () => {
const promise = result.current.run();
resolve(Symbol());
await promise;
});

expect(result.current.delayedAttempt.status).toEqual('success');
const oldDelayedAttempt = result.current.delayedAttempt;

let promise: Promise<[symbol, Error]>;
act(() => {
// Start promise but do not resolve it.
promise = result.current.run();
});

// Wait for delayedAttempt to get updated.
let nextDelayedAttempt: Attempt<symbol>;
await waitFor(() => {
// result.current always returns the current result. Capture the attempt that's being compared
// within waitFor so that we can check its status outside of the block and be sure that it's
// the same attempt.
nextDelayedAttempt = result.current.delayedAttempt;
expect(nextDelayedAttempt).not.toBe(oldDelayedAttempt);
});
expect(nextDelayedAttempt.status).toEqual('processing');

await act(async () => {
// Resolve the promise after the status was updated to processing.
resolve(Symbol());
await promise;
});
expect(result.current.delayedAttempt.status).toEqual('success');
});

it('cancels pending update', async () => {
const delayMs = 100;
let resolve: (symbol: symbol) => void;
const { result } = renderHook(() => {
const [eagerAttempt, run] = useAsync(() => {
// Resolve to a symbol so that successful attempts are not equal to each other.
return new Promise<symbol>(res => {
resolve = res;
});
});
const delayedAttempt = useDelayedRepeatedAttempt(eagerAttempt, delayMs);

return { run, delayedAttempt, eagerAttempt };
});

await act(async () => {
const promise = result.current.run();
resolve(Symbol());
await promise;
});

expect(result.current.delayedAttempt.status).toEqual('success');

let promise: Promise<[symbol, Error]>;
act(() => {
// Start promise but do not resolve it.
promise = result.current.run();
});

// The _eager_ attempt gets updated to a processing state.
// This means that the hook now enqueued a delayed update of delayedAttempt.
expect(result.current.eagerAttempt.status).toEqual('processing');
expect(result.current.delayedAttempt.status).toEqual('success');

await act(async () => {
// Resolve the promise. This transitions eagerAttempt and delayedAttempt to a success state.
resolve(Symbol());
await promise;
});

expect(result.current.eagerAttempt.status).toEqual('success');
expect(result.current.delayedAttempt.status).toEqual('success');

// Wait until the delay. If the pending update was not properly canceled, this should execute
// it. As such, this will count as a state update outside of `act`, which will surface an error.
//
// In case the update does not get canceled, delayedAttempt will not get updated to pending.
// The pending update will call setCurrentAttempt, which will set currentAttempt to processing.
// The hook will reexecute and the effect will set currentAttempt back to success since
// currentAttempt != attempt. This is all because at the time when the pending update gets
// erroneously executed, eagerAttempt is already successful.
await wait(delayMs);
});
});
39 changes: 38 additions & 1 deletion web/packages/shared/hooks/useAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ import { useCallback, useState, useRef, useEffect } from 'react';
* return { fetchUserProfileAttempt, fetchUserProfile };
* }
*
*
* @example In the view layer you can use it like this:
* function UserProfile(props) {
* const { fetchUserProfileAttempt, fetchUserProfile } = useUserProfile(props.id);
Expand Down Expand Up @@ -274,3 +273,41 @@ export function mapAttempt<A, B>(
data: mapFunction(attempt.data),
};
}

/**
* useDelayedRepeatedAttempt makes it so that on repeated calls to `run`, the attempt changes its
* state to 'processing' only after a delay. This can be used to mask repeated calls and
* optimistically show stale results.
*
* @example
* const [eagerFetchUserProfileAttempt, fetchUserProfile] = useAsync(async () => {
* return await fetch(`/users/${userId}`);
* })
* const fetchUserProfileAttempt = useDelayedRepeatedAttempt(eagerFetchUserProfileAttempt, 600)
*/
export function useDelayedRepeatedAttempt<Data>(
attempt: Attempt<Data>,
delayMs = 400
): Attempt<Data> {
const [currentAttempt, setCurrentAttempt] = useState(attempt);

useEffect(() => {
if (
currentAttempt.status === 'success' &&
attempt.status === 'processing'
) {
const timeout = setTimeout(() => {
setCurrentAttempt(attempt);
}, delayMs);
return () => {
clearTimeout(timeout);
};
}

if (currentAttempt !== attempt) {
setCurrentAttempt(attempt);
}
}, [attempt, currentAttempt, delayMs]);

return currentAttempt;
}
7 changes: 6 additions & 1 deletion web/packages/teleterm/src/ui/Vnet/VnetSliderStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { StepComponentProps } from 'design/StepSlider';
import { Box, ButtonSecondary, Flex, Text } from 'design';
import { mergeRefs } from 'shared/libs/mergeRefs';
import { useRefAutoFocus } from 'shared/hooks';
import { useDelayedRepeatedAttempt } from 'shared/hooks/useAsync';

import { ConnectionStatusIndicator } from 'teleterm/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionStatusIndicator';

Expand Down Expand Up @@ -112,7 +113,11 @@ const ErrorText = (props: PropsWithChildren) => (
* optimistically displays previously fetched results while fetching new list.
*/
const DnsZones = () => {
const { listDNSZones, listDNSZonesAttempt } = useVnetContext();
const { listDNSZones, listDNSZonesAttempt: eagerListDNSZonesAttempt } =
useVnetContext();
const listDNSZonesAttempt = useDelayedRepeatedAttempt(
eagerListDNSZonesAttempt
);
const dnsZonesRefreshRequestedRef = useRef(false);

useEffect(function refreshListOnOpen() {
Expand Down

0 comments on commit 6ed1f58

Please sign in to comment.