Skip to content

IS-11570 Add browser back/forward navigation to the HAAPI stepper - #266

Open
aleixsuau wants to merge 8 commits into
devfrom
feature/dev/IS-11570-haapi-stepper-history-navigation
Open

IS-11570 Add browser back/forward navigation to the HAAPI stepper#266
aleixsuau wants to merge 8 commits into
devfrom
feature/dev/IS-11570-haapi-stepper-history-navigation

Conversation

@aleixsuau

@aleixsuau aleixsuau commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Jira: https://curity.atlassian.net/browse/IS-11570

Adds useHaapiStepperHistoryNavigation and mounts <HaapiStepperHistoryNavigation /> in the app, wiring the browser Back/Forward buttons to the HAAPI stepper:

  • Records every reached step as a browser-history entry, mirrored in an in-memory model.
  • Back/Forward re-opens the nearest reproducible step (GET link / GET form) in the direction of travel, skipping non-reproducible ones (POST forms, client operations).
  • Truncates abandoned forward branches, mirroring native pushState behaviour.
  • Skips polling steps entirely so they don't flood the history (they re-issue a GET poll action every interval).

Testing

Run a HAAPI flow and use the browser Back/Forward buttons: reproducible steps (GET links / GET forms) are revisited, non-reproducible ones (POST forms, client operations) are skipped.

Unit tests: npx vitest run src/shared/feature/history.

Deferred: R1 (Back on the first step)

R1 ("Back on the initial step must stay in the app, no reload") is not implemented in this PR. The first step reuses the document's load history entry, so pressing Back from it is a cross-document navigation that reloads the app — no popstate fires, so the hook can't intercept it.

Planned follow-up: seed a sentinel history entry behind the first step at mount, so Back lands on it (a same-document popstate) and snaps forward to the first step — trapping Back inside the flow (confirmed acceptable for the auth flow). The it.skip('R1: …') test documents this gap so it isn't mistaken for covered.

@aleixsuau
aleixsuau requested a review from luisgoncalves July 23, 2026 13:26
@aleixsuau
aleixsuau marked this pull request as ready for review July 27, 2026 08:49
Copilot AI review requested due to automatic review settings July 27, 2026 08:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds browser Back/Forward integration for the HAAPI stepper by persisting step progress into window.history state and replaying reproducible steps when users navigate the browser history.

Changes:

  • Refactors the HistoryNavigation adapter to provide stateless replace/push/go operations and an unsubscribe-based popstate listener API.
  • Introduces useHaapiStepperHistoryNavigation (plus a headless mounting component) to sync stepper history ↔ browser history and skip non-reproducible entries during navigation.
  • Adds unit tests for reproducible-action classification and history navigation behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/haapi-react-app/src/shared/util/browser-apis.ts Refactors the browser history adapter API to support stepper navigation needs (replace/push/go + unsubscribe).
src/haapi-react-app/src/shared/feature/history/useHaapiStepperHistoryNavigation.ts Implements the core stepper↔browser history synchronization and navigation skipping logic.
src/haapi-react-app/src/shared/feature/history/useHaapiStepperHistoryNavigation.spec.ts Adds unit tests covering recording, skipping, snapping back, and forward-branch truncation.
src/haapi-react-app/src/shared/feature/history/reproducible-action.ts Defines what actions/entries are safe to replay via history navigation.
src/haapi-react-app/src/shared/feature/history/reproducible-action.spec.ts Tests reproducibility classification for links, GET/POST forms, and client operations.
src/haapi-react-app/src/shared/feature/history/HaapiStepperHistoryNavigation.tsx Headless component that mounts the hook inside <HaapiStepper>.
src/haapi-react-app/src/App.tsx Mounts <HaapiStepperHistoryNavigation /> inside the app’s stepper tree.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/haapi-react-app/src/shared/feature/history/reproducible-action.ts Outdated
aleixsuau and others added 3 commits July 27, 2026 11:07
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aleixsuau
aleixsuau force-pushed the feature/dev/IS-11570-haapi-stepper-history-navigation branch from 8388b37 to 695cb32 Compare July 27, 2026 09:25
@vahag-curity

Copy link
Copy Markdown
Contributor

some buttons do not show the text
image

const browserHistoryEntryIndex = { index: nextBrowserHistoryIndex } satisfies BrowserHistoryEntryIndex;
if (nextBrowserHistoryIndex === 0) {
// The first step reuses the history entry the app was loaded with instead of pushing a new one.
browserNavigation.replaceEntry(browserHistoryEntryIndex);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

R1 ("Back on the initial step must stay in the app, no reload") isn't implemented.

No guard entry is seeded behind the first step. Confirmed live in the browser: exactly one replaceState({index:0}) and zero pushes before it. Pressing Back from the first step destroyed the JS execution context; the next document came back with window.name reset, navType: "navigate", history.state: null and a server-rendered title — a full cross-document reload out of the SPA, exactly as the ticket's R1 root-cause analysis predicts.

The ticket's plan says "implement R1 now"; if it's deferred to a follow-up, worth stating that in the PR description (see also my comment on the test that currently looks like it covers this).

One caveat for fairness: if the login page is opened in a brand-new tab with nothing behind it, Back is a no-op and R1 accidentally holds. The failure needs a prior entry in the tab — i.e. the normal "user clicked Log in on a client app" case.

);
// If we skipped over non-reproducible entries, move the browser onto the step we actually re-opened.
moveBrowserHistoryTo(destinationBrowserHistoryIndex, currentBrowserHistoryIndex, browserNavigation);
browserHistoryRef.current = { ...browserHistoryRef.current, index: destinationBrowserHistoryIndex };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Concurrent replays desync the cursor from the displayed step, and Back then navigates forward.

advanceHaapiStepperToTheNextStep calls the void, fire-and-forget nextStep, and the cursor is updated unconditionally here with no loading guard. Two Back presses 30 ms apart (well inside one HAAPI round trip), reproduced twice in the browser:

popstate{1} -> popstate{0} -> push{1} -> push{2}
cursor: {index:2}    UI: selector (entry 0)

Both late-resolving replays were mistaken for new steps and appended as forward entries. A single deliberate Back from that state then moved the cursor 2 -> 1 and the UI selector -> login form — i.e. Back moved the user forward. Second run: popstate{1} -> popstate{0} -> push{1}, cursor 1, UI on the login form. go() was never called to correct any of it.

Root cause: isSameStepperHistoryEntry compares only the last stepper entry against the browser entry at the current cursor, so an out-of-order replay looks like a brand-new step. Ignoring popstate while the stepper is loading, and/or tagging replays so their completion is recognised, would close it.

Related but not reproduced: on this same path, if a replay returns a problem step, nextStepAsync early-returns without appending a history entry (HaapiStepper.tsx:326) while the cursor has already moved. I tried to force that with a competing flow in the same session and the replay still succeeded, so I'd treat it as theoretical — but the same fix covers it.

expect(fake.position()).toBe(0); // snapped back onto link@0
});

it('keeps the user in the flow when going back from the first step', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test asserts a popstate the browser cannot emit, so the suite reports coverage for the one behaviour that is actually broken.

fake.navigate(-1) clamps pos to -1 and fires the listener with state = null. In a real browser the first step replaces the loaded entry's state ({index:0}), so there is no in-document null-state entry behind it — going back leaves the document entirely (verified: execution context destroyed, full reload). That makes the findReproducibleBrowserHistoryEntryIndex(...) === -1 -> snap-back path unreachable in the back direction.

The path is genuinely reachable going forward — verified in the browser: popstate{2} -> go(-1) -> popstate{1}. Suggest keeping the forward case and renaming or removing this one until a guard entry actually exists.

entries = [...entries.slice(0, pos + 1), state];
pos = entries.length - 1;
}),
go: vi.fn((delta: number) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fake's go() is synchronous; the real one is async — so the specs can't see a duplicated replay.

Running this hook against the fake as written vs. one that defers move(delta): sync -> nextStep fires twice for a single Back press; async (real-browser behaviour) -> once. The specs don't notice because they assert toHaveBeenCalledWith rather than call counts.

The real browser does behave correctly here (one corrective cycle observed), so this is test fidelity rather than a production bug — but deferring go() and asserting call counts would let the suite detect the re-entrancy Copilot flagged, instead of silently tolerating it.

go(delta: number): void;

/** The state attached to the current history entry (null when none was set). */
getState(): unknown;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

getState() has no consumer.

Added to the interface and to BrowserHistoryNavigation, but a grep across ui-kit/src finds only the declaration, the implementation and the test fake — nothing reads it. Dead API surface unless something downstream is planned.

@vahag-curity

Copy link
Copy Markdown
Contributor

Review summary (IS-11570)

Reviewed against the ticket's acceptance criteria, then verified in a browser against a local Identity Server (Vite dev server on :8443 proxying to idsvr :9443, ui-experience api-driven, real OAuth flows using bankid1 and the html-form / 2FA authenticators). Inline comments carry the details.

Mechanics are clean: tsc -b, eslint, prettier --check and all 15 unit tests pass. The design reads well — single reproducibility predicate, headless mount point, injectable HistoryNavigation.

Confirmed working in the browser:

  • Back/Forward between reproducible steps (selector <-> login form) lands on the right step, no document reload
  • Forward into a non-reproducible frontier snaps back: popstate{2} -> go(-1) -> popstate{1} (AC3 in that direction)
  • Abandoned forward branches are truncated (AC4)

Blocking, in my view: the polling classification and the concurrent-replay desync — both produce user-visible wrong navigation in flows that ship by default — plus R1.

Two findings with no line to hang off:

1. No e2e coverage. test/haapi-react-app-e2e-tests/cypress/e2e already provisions the server and starts a real authorization flow, so cy.go('back') / cy.go('forward') would slot straight in. Worth adding: every defect I found surfaced in the browser and none from the unit suite, and the fake browser diverges from real semantics in at least two ways (see the two spec comments).

2. The ticket's SDK scope isn't in this PR. IS-11570's "In scope", AC5 and Technical Implementation call for historyIndex / canGoBack / canGoForward / back() / forward() in haapi-react-sdk plus a history-navigation.ts; grep finds zero occurrences of any of those anywhere under ui-kit/src, and the history model is reimplemented in the app instead. As a consequence AC3's "that direction is disabled (canGoBack/canGoForward false)" isn't representable. If this is a deliberate re-scope to app-only, the ticket should be updated.

Minor: the ticket says form actions are reproducible when method !== POST; the code uses method === GET. The code is the safer reading (PUT/PATCH/DELETE aren't replay-safe) — the ticket wording is what should change, not the code.

aleixsuau and others added 2 commits July 28, 2026 12:41
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@aleixsuau

Copy link
Copy Markdown
Contributor Author

some buttons do not show the text image

@urre @luisgoncalves I couldn't find this specific case in Confluence. How should we fix it?

aleixsuau and others added 3 commits July 28, 2026 14:47
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…not implemented).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants