Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: [M3-7249] - Linode Landing flickering #9836

Merged
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-9836-fixed-1698158641264.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

Linodes Landing flickering ([#9836](https://github.com/linode/manager/pull/9836))
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';

import { rest, server } from 'src/mocks/testServer';
import { wrapWithTheme } from 'src/utilities/testHelpers';

import MainContent, {
import {
checkFlagsForMainContentBanner,
checkPreferencesForBannerDismissal,
} from './MainContent';
import { queryClientFactory } from './queries/base';

const queryClient = queryClientFactory();

const mainContentBanner = {
key: 'Test Text Key',
Expand Down Expand Up @@ -52,31 +43,3 @@ describe('checkPreferencesForBannerDismissal', () => {
expect(checkPreferencesForBannerDismissal({}, 'key1')).toBe(false);
});
});

describe('Databases menu item for a restricted user', () => {
it('should not render the menu item', async () => {
server.use(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intentions of this test are not clear. We have tests for this case in the PrimaryNav test and AddNewMenu so I'm not sure what this one was intended to check. It was flakey because it tries to render the entire app

rest.get('*/account', (req, res, ctx) => {
return res(ctx.json({}));
})
);
const { getByText } = render(
wrapWithTheme(
<MainContent appIsLoading={false} isLoggedInAsCustomer={true} />,
{
flags: { databases: false },
queryClient,
}
)
);

await waitFor(() => {
let el;
try {
el = getByText('Databases');
} catch (e) {
expect(el).not.toBeDefined();
}
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,6 @@ export class AuthenticationWrapper extends React.Component<CombinedProps> {
!this.state.showChildren
) {
this.makeInitialRequests();

return this.setState({ showChildren: true });
}

/** basically handles for the case where our token is expired or we got a 401 error */
Expand Down Expand Up @@ -142,6 +140,7 @@ export class AuthenticationWrapper extends React.Component<CombinedProps> {
/** We choose to do nothing, relying on the Redux error state. */
} finally {
this.props.markAppAsDoneLoading();
this.setState({ showChildren: true });
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes e2e failure @jdamore-linode mentioned.

PreferenceToggle use to render-block all children until preferences were loaded. Now we are render-blocking at this level, which makes more sense anyway.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/** We choose to do nothing, relying on the Redux error state. */

It's my first time visiting this code in a long time. I understand we to load content and render tree in the case of a fetching error, but what does the redux error do exactly? The user does not get any feedback something fails (ex: try blocking any API request). It's probably beyond the scope of this PR but wondering what your thoughts are about it.

}
};
}
Expand Down
184 changes: 20 additions & 164 deletions packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import { path } from 'ramda';
import * as React from 'react';

import { useMutatePreferences, usePreferences } from 'src/queries/preferences';
import { isNullOrUndefined } from 'src/utilities/nullOrUndefined';

type PreferenceValue = boolean | number | string;

export interface PreferenceToggleProps<T> {
preference: T;
Expand All @@ -18,171 +12,43 @@ interface RenderChildrenProps<T> {

type RenderChildren<T> = (props: RenderChildrenProps<T>) => JSX.Element;

interface Props<T = PreferenceValue> {
interface Props<T> {
children: RenderChildren<T>;
initialSetCallbackFn?: (value: T) => void;
localStorageKey?: string;
preferenceKey: string;
preferenceOptions: [T, T];
toggleCallbackFn?: (value: T) => void;
toggleCallbackFnDebounced?: (value: T) => void;
value?: T;
}

export const PreferenceToggle = <T extends unknown>(props: Props<T>) => {
export const PreferenceToggle = <T,>(props: Props<T>) => {
const {
children,
preferenceKey,
preferenceOptions,
toggleCallbackFn,
toggleCallbackFnDebounced,
value,
} = props;

/** will be undefined and render-block children unless otherwise specified */
const [currentlySetPreference, setPreference] = React.useState<T | undefined>(
value
);
const [lastUpdated, setLastUpdated] = React.useState<number>(0);

const {
data: preferences,
error: preferencesError,
refetch: refetchUserPreferences,
} = usePreferences();
const { data: preferences } = usePreferences();

const { mutateAsync: updateUserPreferences } = useMutatePreferences();

React.useEffect(() => {
/**
* This useEffect is strictly for when the app first loads
* whether we have a preference error or preference data
*/

/**
* if for whatever reason we failed to get the preferences data
* just fallback to some default (the first in the list of options).
*
* Do NOT try and PUT to the API - we don't want to overwrite other unrelated preferences
*/
if (
isNullOrUndefined(currentlySetPreference) &&
!!preferencesError &&
lastUpdated === 0
) {
/**
* get the first set of options
*/
const preferenceToSet = preferenceOptions[0];
setPreference(preferenceToSet);

if (props.initialSetCallbackFn) {
props.initialSetCallbackFn(preferenceToSet);
}
}

/**
* In the case of when we successfully retrieved preferences for the FIRST time,
* set the state to what we got from the server. If the preference
* doesn't exist yet in this user's payload, set defaults in local state.
*/
if (
isNullOrUndefined(currentlySetPreference) &&
!!preferences &&
lastUpdated === 0
) {
const preferenceFromAPI = path<T>([preferenceKey], preferences);

/**
* this is the first time the user is setting the user preference
*
* if the API value is null or undefined, default to the first value that was passed to this component from props.
*/
const preferenceToSet = isNullOrUndefined(preferenceFromAPI)
? preferenceOptions[0]
: preferenceFromAPI;

setPreference(preferenceToSet);

/** run callback function if passed one */
if (props.initialSetCallbackFn) {
props.initialSetCallbackFn(preferenceToSet);
}
}
}, [preferences, preferencesError]);

React.useEffect(() => {
/**
* we only want to update local state if we already have something set in local state
* setting the initial state is the responsibility of the first useEffect
*/
if (!isNullOrUndefined(currentlySetPreference)) {
const debouncedErrorUpdate = setTimeout(() => {
/**
* we have a preference error, so first GET the preferences
* before trying to PUT them.
*
* Don't update anything if the GET fails
*/
if (!!preferencesError && lastUpdated !== 0) {
/** invoke our callback prop if we have one */
if (
toggleCallbackFnDebounced &&
!isNullOrUndefined(currentlySetPreference)
) {
toggleCallbackFnDebounced(currentlySetPreference);
}
refetchUserPreferences()
.then((response) => {
updateUserPreferences({
...response.data,
[preferenceKey]: currentlySetPreference,
}).catch(() => /** swallow the error */ null);
})
.catch(() => /** swallow the error */ null);
} else if (
!!preferences &&
!isNullOrUndefined(currentlySetPreference) &&
lastUpdated !== 0
) {
/**
* PUT to /preferences on every toggle, debounced.
*/
updateUserPreferences({
[preferenceKey]: currentlySetPreference,
}).catch(() => /** swallow the error */ null);

/** invoke our callback prop if we have one */
if (
toggleCallbackFnDebounced &&
!isNullOrUndefined(currentlySetPreference)
) {
toggleCallbackFnDebounced(currentlySetPreference);
}
} else if (lastUpdated === 0) {
/**
* this is the case where the app has just been mounted and the preferences are
* being set in local state for the first time
*/
setLastUpdated(Date.now());
}
}, 500);

return () => clearTimeout(debouncedErrorUpdate);
}

return () => null;
}, [currentlySetPreference]);

const togglePreference = () => {
/** first set local state to the opposite option */
const newPreferenceToSet =
currentlySetPreference === preferenceOptions[0]
? preferenceOptions[1]
: preferenceOptions[0];
let newPreferenceToSet: T;

if (preferences?.[preferenceKey] === undefined) {
// Because we default to preferenceOptions[0], toggling with no preference should pick preferenceOptions[1]
newPreferenceToSet = preferenceOptions[1];
} else if (preferences[preferenceKey] === preferenceOptions[0]) {
newPreferenceToSet = preferenceOptions[1];
} else {
newPreferenceToSet = preferenceOptions[0];
}

/** set the preference in local state */
setPreference(newPreferenceToSet);
updateUserPreferences({
[preferenceKey]: newPreferenceToSet,
}).catch(() => /** swallow the error */ null);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swallow the error? What does it mean for the end user?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It means their updated preferences won't be sent to the API. (this is existing code)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and they won't know anything about it :( How about a TODO follow up item and associated ticket to display a toast (or whatever we decide) to handle this better?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we could toast. I think we decided to swallow the errors because the idea of preferences is that is should be a low-impact failure if that makes any sense.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created M3-7326

/** invoke our callback prop if we have one */
if (toggleCallbackFn) {
Expand All @@ -192,18 +58,8 @@ export const PreferenceToggle = <T extends unknown>(props: Props<T>) => {
return newPreferenceToSet;
};

/**
* render-block the children. We can prevent
* render-blocking by passing a default value as a prop
*
* So if you want to handle local state outside of this component,
* you can do so and pass the value explicitly with the _value_ prop
*/
if (isNullOrUndefined(currentlySetPreference)) {
return null;
}

return typeof children === 'function'
? children({ preference: currentlySetPreference, togglePreference })
: null;
return children({
preference: value ?? preferences?.[preferenceKey] ?? preferenceOptions[0],
togglePreference,
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -188,22 +188,19 @@ class ListLinodes extends React.Component<CombinedProps, State> {
<DocumentTitleSegment segment="Linodes" />
<ProductInformationBanner bannerLocation="Linodes" />
<PreferenceToggle<boolean>
localStorageKey="GROUP_LINODES"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prop was unused and did nothing

preferenceKey="linodes_group_by_tag"
preferenceOptions={[false, true]}
toggleCallbackFnDebounced={sendGroupByAnalytic}
Copy link
Member Author

@bnussman-akamai bnussman-akamai Oct 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we now rely on React Query's state directly, we won't debounce because React Query performs optimistic updates. I don't think this is an issue considering how and where this component is used.

toggleCallbackFn={sendGroupByAnalytic}
>
{({
preference: linodesAreGrouped,
togglePreference: toggleGroupLinodes,
}: PreferenceToggleProps<boolean>) => {
return (
<PreferenceToggle<'grid' | 'list'>
localStorageKey="LINODE_VIEW"
preferenceKey="linodes_view_style"
preferenceOptions={['list', 'grid']}
toggleCallbackFn={this.changeViewInstant}
toggleCallbackFnDebounced={this.changeViewDelayed}
toggleCallbackFn={this.changeView}
/**
* we want the URL query param to take priority here, but if it's
* undefined, just use the user preference
Expand Down Expand Up @@ -338,17 +335,9 @@ class ListLinodes extends React.Component<CombinedProps, State> {
);
}

/**
* when you change the linode view, send analytics event, debounced.
*/
changeViewDelayed = (style: 'grid' | 'list') => {
changeView = (style: 'grid' | 'list') => {
sendLinodesViewEvent(eventCategory, style);
};

/**
* when you change the linode view, instantly update the query params
*/
changeViewInstant = (style: 'grid' | 'list') => {
const { history, location } = this.props;

const query = new URLSearchParams(location.search);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,9 @@ describe('AddNewMenu', () => {
});

test('does not render hidden menu item - databases', () => {
const mockedUseFlags = jest.fn().mockReturnValue({ databases: false });
jest.mock('src/hooks/useFlags', () => ({
__esModule: true,
useFlags: mockedUseFlags,
}));

const { getByText, queryByText } = renderWithTheme(<AddNewMenu />);
const { getByText, queryByText } = renderWithTheme(<AddNewMenu />, {
flags: { databases: false },
});
const createButton = getByText('Create');
fireEvent.click(createButton);
const hiddenMenuItem = queryByText('Create Database');
Expand Down
Loading