-
Notifications
You must be signed in to change notification settings - Fork 357
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
Changes from 7 commits
ee56a25
a87259a
abaf5ae
e8c1be5
473c710
ac73415
eaecf6a
3966d0b
ec5f134
91e98b1
67adbf4
bd1967d
2832482
eb7ae37
d8af7a1
ab8bcfe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
---|---|---|
|
@@ -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 */ | ||
|
@@ -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 }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixes e2e failure @jdamore-linode mentioned.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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. |
||
} | ||
}; | ||
} | ||
|
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; | ||
|
@@ -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); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Swallow the error? What does it mean for the end user? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
@@ -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 |
---|---|---|
|
@@ -188,22 +188,19 @@ class ListLinodes extends React.Component<CombinedProps, State> { | |
<DocumentTitleSegment segment="Linodes" /> | ||
<ProductInformationBanner bannerLocation="Linodes" /> | ||
<PreferenceToggle<boolean> | ||
localStorageKey="GROUP_LINODES" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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); | ||
|
There was a problem hiding this comment.
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 andAddNewMenu
so I'm not sure what this one was intended to check. It was flakey because it tries to render the entire app