From 834abfdfb0dd71da842687b1bf4998eee717a7a7 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 18:37:39 +0200 Subject: [PATCH] fix(spa): route internal links client-side so the DEK survives Plain links to in-app routes were causing full page reloads. The DEK lives only in memory (session.svelte.ts), so every reload drops it and private rows can't decrypt until the user signs in again. Earlier fix (03ac99e) made the post-reload state usable; this one removes the reload in the first place. New helper lib/navigate.ts: - navigate(path): pushState + dispatch synthetic popstate so the existing window popstate listener in App.svelte re-parses and applies the route. SPA state (DEK, decrypted activities, scroll position) is preserved. - onSpaLink(event, path): swallow plain left-click only. Modified clicks (Cmd/Ctrl/Shift/Alt, middle-click, right-click) fall through to the browser so "open in new tab", copy-link-address, and screen-reader behaviour all still work. Applied to the five internal anchors in ActivityRow.svelte: permalink title (private + non-private), tag chips (private + non-private), and the owner attribution link to //liste. Verified in the browser: clicking a permalink now preserves a window.__spaProbe marker; back-button likewise stays in-app. --- frontend/src/components/ActivityRow.svelte | 20 +++++++++--- frontend/src/lib/navigate.ts | 36 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/navigate.ts diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index a330d7d..2aa0acd 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -5,6 +5,7 @@ type PrivatePayload, } from '../lib/crypto'; import { api } from '../lib/api'; + import { onSpaLink } from '../lib/navigate'; import { privateTagIndex } from '../lib/tagIndex'; import type { Activity } from '../../../shared/types'; @@ -199,7 +200,9 @@ {#if activity.visibility === 'private'} {#if decrypted}

- + onSpaLink(e, `/aktivitet/${activity.id}`)} + style="color: inherit; text-decoration: none;"> {decrypted.title} Privat @@ -215,7 +218,9 @@ {#if decrypted.tags.length}
{#each decrypted.tags as t} - onSpaLink(e, `/etiketter/${encodeURIComponent(t)}`)} + class="tag private" style="text-decoration: none; color: inherit;">{t} {/each}
@@ -236,7 +241,9 @@ {/if} {:else}

- + onSpaLink(e, `/aktivitet/${activity.id}`)} + style="color: inherit; text-decoration: none;"> {activity.title} @@ -258,7 +265,9 @@ {#if activity.tags.length}
{#each activity.tags as t} - onSpaLink(e, `/etiketter/${encodeURIComponent(t)}`)} + class="tag" style="text-decoration: none; color: inherit;">{t} {/each}
@@ -271,7 +280,8 @@

Lagt til av {#if activity.owner_username} - {activity.owner_display} + onSpaLink(e, `/${activity.owner_username}/liste`)}>{activity.owner_display} {:else} {activity.owner_display} {/if} diff --git a/frontend/src/lib/navigate.ts b/frontend/src/lib/navigate.ts new file mode 100644 index 0000000..929c8c1 --- /dev/null +++ b/frontend/src/lib/navigate.ts @@ -0,0 +1,36 @@ +/** + * Client-side navigation helper for the SPA. + * + * Why this exists: a plain `` to an in-app route causes a + * full page reload. The DEK lives only in memory (see session.svelte.ts) + * and gets dropped on reload, which means clicking an internal link as a + * regular browser navigation makes private content un-decryptable. We + * route in-app navigation through history.pushState instead so the SPA + * keeps its in-memory state (DEK, decrypted activities, scroll position). + * + * Links still keep their `href` attribute so: + * - Right-click → "Open in new tab" works + * - Cmd/Ctrl + click opens in a new tab + * - Middle-click opens in a new tab + * - Copy-link-address works + * - Screen readers announce them as links + * onSpaLink only swallows the *plain* left-click. + */ + +export function navigate(path: string): void { + if (window.location.pathname === path) return; + window.history.pushState({}, '', path); + // App.svelte's popstate listener owns the "parse + apply route" logic; + // dispatch one ourselves so pushState behaves the same as back/forward. + window.dispatchEvent(new PopStateEvent('popstate')); +} + +export function onSpaLink(e: MouseEvent, path: string): void { + // Let the browser handle modified clicks (new tab, new window, download) + // and right/middle clicks. We only take over the plain left-click. + if (e.defaultPrevented) return; + if (e.button !== 0) return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + e.preventDefault(); + navigate(path); +}