Skip to content

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

Open
wants to merge 22 commits into
base: main
Choose a base branch
from

Conversation

olemartinorg
Copy link
Contributor

@olemartinorg olemartinorg commented Aug 12, 2025

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 the useEffect() 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:

  • Relying on 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.
  • When calling setSearchParams() (from useSearchParams() from react-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.
  • React router location (from useLocation() etc) and the window.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 (calling setSearchParams()) when that would do more harm than good.
  • If you set the search param to focus a component and that component doesn't exist, the search param will just linger. If you then navigated to another page where that component was present, you'd end up getting focus (although way too late). For this reason I added code to clean up those search params on page navigation.
  • Using a 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)

  • closes #{issue number}

Verification/QA

  • Manual functionality testing
    • I have tested these changes manually
    • Creator of the original issue (or service owner) has been contacted for manual testing (or will be contacted when released in alpha)
    • No testing done/necessary
  • Automated tests
    • Unit test(s) have been added/updated
    • Cypress E2E test(s) have been added/updated
    • No automatic tests are needed here (no functional changes/additions)
    • I want someone to help me make some tests
  • UU/WCAG (follow these guidelines until we have our own)
    • I have tested with a screen reader/keyboard navigation/automated wcag validator
    • No testing done/necessary (no DOM/visual changes)
    • I want someone to help me perform accessibility testing
  • User documentation @ altinn-studio-docs
    • Has been added/updated
    • No functionality has been changed/added, so no documentation is needed
    • I will do that later/have created an issue
  • Support in Altinn Studio
    • Issue(s) created for support in Studio
    • This change/feature does not require any changes to Altinn Studio
  • Sprint board
    • The original issue (or this PR itself) has been added to the Team Apps project and to the current sprint board
    • I don't have permissions to do that, please help me out
  • Labels
    • I have added a kind/* and backport* label to this PR for proper release notes grouping
    • I don't have permissions to add labels, please help me out

…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.
@olemartinorg olemartinorg self-assigned this Aug 12, 2025
@olemartinorg olemartinorg added kind/bug Something isn't working backport-ignore This PR is a new feature and should not be cherry-picked onto release branches labels Aug 12, 2025
@olemartinorg olemartinorg requested a review from cammiida August 12, 2025 07:51
@olemartinorg olemartinorg moved this to 🔎 Review in Team Apps Aug 14, 2025
Copy link
Contributor

@cammiida cammiida left a comment

Choose a reason for hiding this comment

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

🙌

Ole Martin Handeland added 14 commits August 14, 2025 14:09
…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).
… 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.
… 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.
@olemartinorg olemartinorg moved this from 🔎 Review to 👷 In Progress in Team Apps Aug 18, 2025
Ole Martin Handeland added 6 commits August 18, 2025 11:55
…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!
@olemartinorg olemartinorg requested a review from cammiida August 18, 2025 14:44
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
38.3% Coverage on New Code (required ≥ 45%)
40.87% Condition Coverage on New Code (required ≥ 45%)

See analysis details on SonarQube Cloud

Copy link
Contributor

@cammiida cammiida left a 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 😄

Comment on lines +278 to 291
// 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) });
Copy link
Contributor

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)) {
Copy link
Contributor

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?

Comment on lines +444 to +455
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);
Copy link
Contributor

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;
Copy link
Contributor

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 () => {
Copy link
Contributor

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;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const exitSubform = options?.searchParams?.has(SearchParams.ExitSubform, 'true') ?? false;
const shouldExitSubform = options?.searchParams?.has(SearchParams.ExitSubform, 'true') ?? false;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
backport-ignore This PR is a new feature and should not be cherry-picked onto release branches kind/bug Something isn't working
Projects
Status: 👷 In Progress
Development

Successfully merging this pull request may close these issues.

2 participants