fix(nav): replace static back-link hrefs with history.back() + fallback #185
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Back navigation buttons across the app use static
hrefvalues (e.g.href="/persons"). Because many pages have multiple entry points (person detail reachable from search, document view, briefwechsel, etc.), the static link often navigates to the wrong place and confuses users. The browser back button works correctly — the in-app buttons don't.Affected files (11 locations)
persons/new/+page.svelte:10/personspersons/new/+page.svelte:130/personspersons/[id]/+page.svelte:53/personspersons/[id]/edit/+page.svelte:16/persons/{person.id}documents/[id]/edit/+page.svelte:23/documents/{doc.id}documents/[id]/edit/SaveBar.svelte:77/documents/{docId}admin/users/[id]/+page.svelte:37/admin/usersadmin/users/[id]/+page.svelte:123/admin/usersnotifications/+page.svelte:71/enrich/+page.svelte:13/enrich/[id]/+page.svelte:49/enrichSolution
Replace each
<a href="...">Zurück</a>back link with a<button>that callshistory.back(), with a sensible fallback for deep-link entry (no history):The fallback URL should be the natural parent route for each page (same as the current static href).
Acceptance criteria
history.back()as the primary action👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
history.length > 1is a leaky signal. It tells you "has any navigation happened in this tab" — not "can I navigate back within this app." A user who arrived from an external link (email, Google) and navigated once will havehistory.length === 2, sohistory.back()fires instead of the fallback — and sends them off-site. That's identical to the browser back button's behavior, which is probably fine, but it's worth a conscious comment in the code so the next developer understands the trade-off.SSR guard:
historyis a browser-only API. It won't be evaluated in anonclickhandler (which only fires in the browser), so there's no SSR crash risk as written. But ifhistory.lengthever moves into a$derived, a Svelte$effect, or a lifecycle hook, abrowserguard from$app/environmentwill be needed.history.back()bypasses SvelteKit's nav lifecycle.beforeNavigateandafterNavigatehooks won't fire when the user clicks back. This is fine for the current app, but document edit pages (documents/[id]/edit,persons/[id]/edit) may grow unsaved-changes guards later.history.back()would silently skip them. Worth a// NOTE:comment on those specific pages.The 4 "check" locations need auditing.
users/[id],notifications/,enrich/,enrich/[id]are listed as "check" without a confirmed fallback URL. The issue can't be fully closed until those fallbacks are defined.Suggestions
$lib/navigation.ts. Ten duplications of the same three-line function isn't clean. A sharedgoBack(fallback: string)in$lib/navigation.tsreduces it to a single source. A JSDoc comment explaining thehistory.lengthtrade-off belongs there too.<BackButton fallback={string} label={string}>component that co-locates the arrow SVG, class list, and function call. If the visual design ever changes (e.g. adding a keyboard shortcut or changing the animation), it changes in one place.persons/[id]/edit→ fallbackpersons/{person.id}(detail page) is right.documents/[id]/edit→ fallbackdocuments/{doc.id}is right. Confirm the SaveBar's duplicate fallback (SaveBar.svelte:77) resolves identically to the top-of-page button.🏗️ Markus Keller — Application Architect
Questions & Observations
This is a pure frontend refactor with no backend surface and no data model changes. The scope is tight and well-defined — good.
history.back()and SvelteKit's navigation model are separate systems. Whenhistory.back()fires, SvelteKit'sbeforeNavigate/afterNavigatehooks do not run. Today that's harmless. But if navigation guards (e.g., unsaved-changes confirmation dialogs) are introduced later, they will work forgoto()and link-clicks but silently skiphistory.back(). This is a trade-off, not a blocker — but it should be a deliberate decision with a comment, not an accidental omission discovered later.Ten duplications signal a missing module boundary. The
goBack(fallback)logic belongs in$lib/navigation.ts, not inlined in 10 components. At this scale, extraction is worth the one-time cost. Keep the function small and well-named:navigateBack(fallback: string): void.The fallback URLs are hardcoded strings. That's fine and intentional — there's nothing dynamic here. But if route paths ever change (e.g.
/persons→/people), 10 fallback strings become a maintenance task. A route constants module ($lib/routes.ts) exportingPERSONS = '/persons'etc. would make a future rename a one-line change. Whether that's worth doing now depends on how stable the route structure is.Suggestions
goBackto$lib/navigation.tswith a brief JSDoc comment explaining thehistory.lengthheuristic and its known edge case (off-site history entries).$lib/routes.tsconstants file for the fallback URLs, particularly for routes that appear in more than one fallback (e.g./personsappears at least 3 times across the 10 locations).🧪 Sara Holt — QA Engineer
Questions & Observations
history.back()is tricky to test. Vitest/JSDOM does not implementhistory.back()— calling it is a no-op. The behavior can only be meaningfully verified in a real browser (Playwright). The acceptance criteria need Playwright coverage, not just visual inspection.Missing AC: what does "no history" actually look like?
history.length === 1is the cold-open case. But there's a gap: what if the user navigated via an external URL withhistory.length === 2? The fallback won't trigger, andhistory.back()navigates off-site. Is this tested as an accepted behavior, or does the team want to handle it differently?The "4 to check" locations block sign-off.
users/[id],notifications/,enrich/,enrich/[id]need confirmed fallback routes before this can be called done. The AC says "A static fallback route is defined for each button" — that means every single location must be verified, not just the 6 listed ones.No existing regression test for back navigation. The current static
<a>links have no test. This change is an opportunity to add baseline coverage.Suggestions
expect(await backButton.boundingBox()).toHaveProperty('height', expect.toBeGreaterThanOrEqual(44)).axe-corescan in the E2E suite — this won't be caught by visual QA.goBackis extracted to$lib/navigation.ts, add a Vitest unit test for the fallback branch by mockinghistoryas{ length: 1, back: vi.fn() }and verifyinggotois called with the fallback string.🔐 Nora Steiner — Security Engineer
Questions & Observations
Minimal security surface. This is a navigation UX change with no server-side component and no user input flowing into any URL construction. No injection risk, no auth/authz changes.
Open-redirect check (ruled out). The
goto(fallback)call uses a hardcoded string literal as the fallback (e.g.,'/persons'). As long as the fallback value is never derived from$page.url, URL search params, route data, or any user-controlled input, there is no open-redirect risk. The issue spec confirms this: "same as the current static href." Verify in implementation that no fallback is constructed dynamically.history.back()can navigate off-site. If the user arrived from an external URL,history.back()sends them back to that external page. This is identical to the browser's native back button — it's expected behavior, not a security issue. Confirming it's intentional.<a>→<button>is a minor security improvement. The original<a href="/persons">could be crawled, pre-fetched by the browser, or indexed by link checkers as an explicit navigation target. A<button onclick>with nohrefhas no pre-fetch surface. Small win.Suggestions
goBack(fallback: string)utility (if extracted), add a brief comment:// fallback must be a static route string — never pass URL params or user input. This makes the invariant explicit for future developers and auditors, preventing a future "let me make this dynamic" refactor that would introduce an open-redirect.🎨 Leonie Voss — UI/UX & Accessibility
Questions & Observations
Touch target is missing from the code example. The existing class list (
inline-flex items-center text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-brand-navy transition-colors group mb-4) has nomin-h-[44px]. The AC correctly requires ≥ 44px, but the sample implementation doesn't include it. This is the most important change for the senior audience — needs to be added explicitly."Zurück" vs "Zurück zur Übersicht". The CLAUDE.md back link pattern shows
Zurück zur Übersichtas the full visible text. The issue example shows justZurück. Shorter label = less screen reader context. If a page has only one back button, "Zurück" is fine. But if multiple interactive elements compete for attention, screen reader users benefit from "Zurück zur Personenübersicht" (for example). Is this a deliberate simplification, or was the existing contextual text being dropped unintentionally?Focus ring is absent. The class list doesn't include
focus-visible:ring-2 focus-visible:ring-brand-navy. Tailwind CSS resets the browser's default focus outline, so a<button>with no explicit focus style is invisible to keyboard users. All interactive elements in this project need a visible focus ring.Semantic correctness: ✅ Replacing
<a href>with<button>is exactly right. Back navigation is an action, not a link to a stable URL. This also eliminates the incorrect semantic of a link with a destination that may not reflect where you'll actually end up.Hover animation:
group-hover:-translate-x-1on the arrow SVG carries over cleanly to<button class="... group">. No issue.Suggestions
min-h-[44px] focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 outline-nonearia-label="Zurück zur [entity]Übersicht"per page so screen readers retain context.<button>form once implemented — the current docs show an<a>pattern that will be obsolete after this change.⚙️ Tobias Wendt — DevOps & Platform
Questions & Observations
Zero infra impact. No Docker Compose changes, no new environment variables, no CI pipeline changes, no new npm dependencies.
historyis a browser API;gotois already bundled via$app/navigation.Build pipeline:
npm run check(svelte-check + TypeScript) andnpm run lintwill catch type errors and formatting issues in the changed files. No new CI steps needed.SSR note (for completeness):
historyis not available in the Node.js SSR context. Since theonclickhandler only runs in the browser, this is safe as written. If thegoBackutility is ever called outside an event handler, wrap it with abrowserguard from$app/environment. Not a current risk.No new npm packages. The implementation as described uses only existing runtime APIs. Good — no supply chain surface added.
Suggestions
.sveltefiles. Standard PR checks cover it.🎯 Discussion Resolutions
After reviewing the persona feedback with the user, here are the agreed decisions:
Theme 1 — Scope: affected locations
admin/users/[id]:129(labeled Cancel) stays as a static<a href="/admin/users">— it is a form cancel button, not back navigation. Replacing it withhistory.back()would be semantically wrong.notifications/+page.svelteis dropped — the file no longer exists (route was renamed to/aktivitaeten, which has no back button).Theme 2 — Navigation behaviour
history.back()with no fallback and nohistory.lengthcheck.Theme 3 — SvelteKit lifecycle guards
history.back()triggers a browserpopstateevent, which SvelteKit intercepts.beforeNavigatefires normally for in-app navigation.useUnsavedWarninghook is compatible as-is.Theme 4 — Shared component
<BackButton>component insrc/lib/components/.history.back()internally and encapsulates the arrow SVG, class list, touch target, focus ring, and hover animation.labelprop, nofallbackprop.Theme 5 — Testing
frontend/e2e/:boundingBox()) + axe-core scan.Theme 6 — Accessibility
<BackButton>class list includesmin-h-[44px] focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 outline-none.history.back().Theme 7 — Security
Theme 8 — CLAUDE.md back link pattern
<a href>form to the new<BackButton>component form once implemented.Open / Skipped
None — all raised points were resolved.
These resolutions are the authoritative design for implementation. The
implementskill will read this comment alongside the original issue.✅ Implementation Complete
All tasks from the implementation plan are done. Here's what was built:
Commits
feat(nav): add BackButton component calling history.back()src/lib/components/BackButton.svelte— button with inline SVG arrow,m.btn_back()label,history.back()on click,min-h-[44px]touch target,focus-visible:ring-focus-ringfocus ringsrc/lib/components/BackButton.svelte.spec.ts— 3 unit tests: renders "Zurück", callshistory.back()on click, hasmin-h-[44px]classrefactor(nav): replace static back-link hrefs with BackButtonpersons/new/+page.svelte:10✅persons/[id]/+page.svelte:53✅persons/[id]/edit/+page.svelte:16✅documents/[id]/edit/+page.svelte:29✅admin/users/[id]/+page.svelte:26✅ (inline<button onclick>— icon-only panel chevron, different layout)enrich/+page.svelte:12✅enrich/[id]/+page.svelte:17✅docs(claude): update back link pattern to use BackButton component<a href>pattern to<BackButton>patterntest(nav): add E2E tests for BackButton navigation and accessibilitye2e/back-button.spec.ts— navigation test (history.back() returns to actual previous URL, not static href) + accessibility test (touch target ≥ 44px + axe-core wcag2a/wcag2aa scan)Scope adjustments vs. issue
admin/users/[id]:129(Cancel button) — kept as static<a>, not back navigationpersons/new/+page.svelte:130(Cancel button) — kept as static<a>, same reasondocuments/[id]/edit/+page.svelte:75(Cancel button) — kept as static<a>, same reasonnotifications/+page.svelte— dropped (file no longer exists; route renamed to/aktivitaetenwhich has no back button)Test results
ChronikErrorCardfailure unrelated to this change)Branch:
feat/issue-185-fix-nav-history-back