-
Notifications
You must be signed in to change notification settings - Fork 31
Fixing flaky navigation handling #3593
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
base: main
Are you sure you want to change the base?
Conversation
…ror report should focus the correct field") a field is unfocused and navigated to again right afterwards. The render runs every time, but the useEffect() does not - those are batched up and runa at the end. This caused the field to not always get focus, making the test flaky.
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.
🙌
…he dropdown was clicked again. When this didn't wait to observe the effect of the {esc}, that test failed.
…op after the page navigation and the dateOfEffect field would no longer be in focus. This will force the error to be clear (the page has scrolled somewhere else), instead of the effect of that being what fails the test (clicking the button does not open the dialog).
…lready be added other ways.
… We specifically don't scroll back to the top after navigating when we're navigating AND focusing on a component, but this thing did that anyway. Since we have our own functionality here, there's no point in using this component.
… (some tests failed)
…different sources
… this was adding `hasFocus` to the deps array, but that only fixes some cases. Since the render call runs, but the useEffect() does not, I'll opt for trying out a setTimeout() instead. This seems to be more stable.
…ms and re-render the useNavigatePage()-calling component in time before navigation. By always removing these on navigation we prevent weird behaviour when navigating back to a page afterwards.
…sComponentId after, and the result being that the component isn't focused properly. Cleaning this up to only set state once to hopefully fix that case.
…preventing cleanup when it will cause navigation or something else unanticipated.
…ind an element inside as well (this also prevents waiting for 2sec for components without inputs, such as Subform). Also verifying that location is actually updated as well, as I got problems with that in some cases - navigation state is even idle at that point!
|
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.
Veldig bra jobba! 👏 tror kanskje ikke alt her er helt nødvendig, så har prøvd å forenkle bittelitt. Approver igjen med forbehold om at i hvert fall kommentaren for GenericComponent
og sletting av search params i useNavigatePage
blir tatt hensyn til 😄
// If these are not explicitly set in the incoming search params, we want to remove them when navigating to the | ||
// next page. Without this we'll potentially drag old params with us, which trigger focus when navigating back | ||
// again. This can happen when a page navigation happens before we got a chance to clean up the search params, and | ||
// will happen fairly regularly in Cypress tests (simply because it clicks around so quickly). | ||
const paramsToRemove = [SearchParams.FocusComponentId, SearchParams.FocusErrorBinding, SearchParams.ExitSubform]; | ||
for (const param of paramsToRemove) { | ||
if (newSearchParams.has(param) && !options?.searchParams?.has(param)) { | ||
newSearchParams.delete(param); | ||
} | ||
} | ||
|
||
if (isStatelessApp) { | ||
const url = `/${page}${queryKeysRef.current}`; | ||
const url = `/${page}?${newSearchParams}`; | ||
return navigate(url, options, { replace }, { targetLocation: url, callback: () => focusMainContent(options) }); |
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.
Tror ikke det skal være nødvendig å synce forrige search params med de som blir sendt inn i funksjonen siden navigate ikke tar dem med? Med mindre man spesifiserer dem ofc. Burde man ikke bare bruke de som blir sendt inn gjennom options? Altså fjerne 282-286 og sette 289-291 til
if (isStatelessApp) {
const url = `/${page}`
navigate(url, options, { replace }, { targetLocation: url, callback: () => focusMainContent(options) });
Samme under da, I guess.
@@ -416,8 +406,9 @@ export function useNavigatePage() { | |||
} | |||
|
|||
export function focusMainContent(options?: NavigateToPageOptions) { | |||
if (options?.shouldFocusComponent !== true) { | |||
document.getElementById('main-content')?.focus({ preventScroll: true }); | |||
if (!options?.searchParams?.has(SearchParams.FocusComponentId)) { |
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.
Nice! Lurer på om noe lignende også burde gjøres i NavigationBarComponent
?
const newSearchParams = new URLSearchParams(searchParams); | ||
newSearchParams.set(SearchParams.FocusComponentId, indexedId); | ||
errorBindingKey && newSearchParams.set(SearchParams.FocusErrorBinding, errorBindingKey); | ||
await navigateToPage(targetPage, { | ||
...options?.pageNavOptions, | ||
shouldFocusComponent: true, | ||
focusComponentId: indexedId, | ||
searchParams, | ||
searchParams: newSearchParams, | ||
replace: !!searchParams.get(SearchParams.FocusComponentId) || !!searchParams.get(SearchParams.ExitSubform), | ||
}); | ||
} else { | ||
setSearchParams((prev) => { | ||
prev.set(SearchParams.FocusComponentId, indexedId); | ||
errorBindingKey && prev.set(SearchParams.FocusErrorBinding, errorBindingKey); |
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.
Fjerner litt duplisering av logikk?
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set(SearchParams.FocusComponentId, indexedId);
const errorBindingKey = options?.error?.['bindingKey'];
if (errorBindingKey) {
newSearchParams.set(SearchParams.FocusErrorBinding, errorBindingKey);
}
if (targetPage && targetPage !== currentPageId) {
await navigateToPage(targetPage, {
...options?.pageNavOptions,
shouldFocusComponent: true,
focusComponentId: indexedId,
searchParams: newSearchParams,
replace:
!!newSearchParams.get(SearchParams.FocusComponentId) || !!newSearchParams.get(SearchParams.ExitSubform),
});
} else {
setSearchParams(newSearchParams);
}
}); | ||
const searchParams = new URLSearchParams(); | ||
searchParams.set(SearchParams.ExitSubform, 'true'); | ||
const navigateTo = navParams.current.componentId; |
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.
navigateTo
sier ikke så mye om hva det er og høres ut som en funskjon 🙈 er det en funksjon, en konst, eller noe helt annet? Hva men noe ala componentToNavigateTo
. Bittelitt langt, men eksplisitt i hvert fall, med mindre du har andre forslag 😄
const shouldFocus = indexedId && indexedId == nodeId && !isNavigating && locationIsUpdated; | ||
|
||
shouldFocus && | ||
setTimeout(async () => { |
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.
setTimeout
her tror jeg ikke trengs, og tror resten her burde inn i en useEffect
? Kanskje, men er åpen for at det ikke må det 😄 Det her funker i hvert fall over 100 runs for meg
function useHandleFocusComponent(nodeId: string, containerDivRef: React.RefObject<HTMLDivElement | null>) {
const [searchParams, setSearchParams] = useSearchParams();
const indexedId = searchParams.get(SearchParams.FocusComponentId);
const errorBinding = searchParams.get(SearchParams.FocusErrorBinding);
const isNavigating = useNavigation().state !== 'idle';
const location = useLocation();
const abortController = useRef(new AbortController());
const hashWas = window.location.hash;
const locationIsUpdated = hashWas.endsWith(location.search);
const shouldFocus = indexedId && indexedId == nodeId && !isNavigating && locationIsUpdated;
useEffect(() => {
async function focusComponent() {
try {
const div = await waitForRef(containerDivRef, 2000, abortController.current.signal);
requestAnimationFrame(() => {
!abortController.current.signal.aborted && div.scrollIntoView({ behavior: 'instant' });
});
const field = findElementToFocus(div, errorBinding);
if (field && !abortController.current.signal.aborted) {
field.focus();
}
} catch (error) {
if (!abortController.current.signal.aborted) {
console.error('Failed to focus component', nodeId, error);
}
} finally {
if (!abortController.current.signal.aborted && hashWas === window.location.hash) {
// Only cleanup when hash is the same as what it was during render. Navigation might have occurred, especially
// in Cypress tests where state changes will happen rapidly. These search params are cleaned up in
// useNavigatePage() automatically, so it shouldn't be a problem if the page has been changed. If something
// else happens, we'll re-render and get a new chance to clean up later.
cleanupQuery(searchParams, setSearchParams);
}
}
}
shouldFocus && focusComponent();
}, [containerDivRef, errorBinding, hashWas, nodeId, searchParams, setSearchParams, shouldFocus]);
useEffect(
() => () => {
// Abort on unmount so that we do not keep trying to focus this component
abortController.current.abort();
},
[abortController],
);
}
@@ -253,68 +250,59 @@ export function useNavigatePage() { | |||
|
|||
const navigateToPage = useCallback( | |||
async (page?: string, options?: NavigateToPageOptions) => { | |||
const exitSubform = options?.searchParams?.has(SearchParams.ExitSubform, 'true') ?? false; |
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.
const exitSubform = options?.searchParams?.has(SearchParams.ExitSubform, 'true') ?? false; | |
const shouldExitSubform = options?.searchParams?.has(SearchParams.ExitSubform, 'true') ?? false; |
Description
In cypress (specifically
frontend-test/validation.ts
-> "Clicking the error report should focus the correct field") a field is unfocused and navigated to again right afterwards. The render runs every time, but theuseEffect()
did not - those are batched up to run at the end. This caused the field to not always get focus, making the test flaky. I found this problem when the test failed every ~3-4 runs in #3552, and I suspect it might have been introduced in #3565.The rest of this PR became a whack-a-mole to try to get tests stable again. As it stands now, the above mentioned test seems very stable to me, but I had to rework the solution here multiple times to get every test green.
My findings:
useEffect()
alone doesn't always cut it. We want to schedule focusing on the correct element for after the component has rendered, but in practice this was difficult. As mentioned above, when deps don't change, the effect will not run again.setSearchParams()
(fromuseSearchParams()
fromreact-router
) it seems to also navigate you back to the previous page if you called it after a navigation but before your render call got that new state.useLocation()
etc) and thewindow.location.hash
are often not in sync during navigation. Because it will take some time before react-router gives you the new state, the code could end up trying to focus a component when it shouldn't, or clean up search params (callingsetSearchParams()
) when that would do more harm than good.setTimeout()
to defer the focusing until after render was much more stable, but caused some new problems. For instance, when a component unmounts we want to clean up that ongoing process and stop the focusing effort.Related Issue(s)
Verification/QA
kind/*
andbackport*
label to this PR for proper release notes grouping