From 145c50147a9392fcae090e0abf516f392f837040 Mon Sep 17 00:00:00 2001 From: Zhekinmaksim Date: Wed, 3 Jun 2026 21:30:41 +0500 Subject: [PATCH 1/2] fix(routing): resolve direct/path-based deep links to hash routes --- frontend/src/App.svelte | 23 ++---- frontend/src/components/Sidebar.svelte | 62 ++++++++-------- frontend/src/lib/normalizePath.js | 48 +++++++++++++ frontend/src/tests/normalizePath.test.js | 91 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 47 deletions(-) create mode 100644 frontend/src/lib/normalizePath.js create mode 100644 frontend/src/tests/normalizePath.test.js diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 6ddfa3f1..1a21b01c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -11,6 +11,7 @@ import { location } from 'svelte-spa-router'; import { resetPageMeta } from './lib/meta.js'; import { authState, verifyAuth } from './lib/auth.js'; + import { normalizeLocation } from './lib/normalizePath.js'; // Early OAuth result detection — runs before routes mount. // Backend redirects here with ?oauth_platform=X&oauth_verified=true/false&oauth_error=... @@ -49,22 +50,12 @@ } } - // The portal uses hash routing. Normalize pasted/local links such as - // /claim/poap/:token so mint links work on localhost and 127.0.0.1. - { - const path = window.location.pathname; - const shouldNormalize = - !window.location.hash && - (path.startsWith('/claim/poap/') || path.startsWith('/community/poaps/')); - - if (shouldNormalize) { - window.history.replaceState( - {}, - '', - `/#${path}${window.location.search || ''}` - ); - } - } + // The portal uses hash routing. Direct/path-based links (sidebar hrefs + // opened in a new tab, refreshes of a path route, shared or indexed links) + // arrive without a hash and would otherwise 404. Rewrite any such path into + // its hash equivalent so the router can resolve it; unknown paths still + // fall through to the router's own NotFound view. + normalizeLocation(window); // State for sidebar toggle on mobile and collapse on desktop let sidebarOpen = $state(false); diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index 19d78260..791ec946 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -182,7 +182,7 @@ {#if !collapsed && getActiveSection() === 'global'}
{ e.preventDefault(); navigate('/testnets'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/testnets') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -191,7 +191,7 @@ Testnets { e.preventDefault(); navigate('/metrics'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/metrics') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -235,7 +235,7 @@ {#if !collapsed && getActiveSection() === 'builder'}
{ e.preventDefault(); navigate('/builders/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/contributions') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -244,7 +244,7 @@ Contributions { e.preventDefault(); navigate('/builders/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/leaderboard') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -253,7 +253,7 @@ Leaderboard { e.preventDefault(); navigate('/builders/resources'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/resources') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -288,7 +288,7 @@ {#if !collapsed && getActiveSection() === 'validator'}
{ e.preventDefault(); navigate('/validators/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/contributions') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -297,7 +297,7 @@ Contributions { e.preventDefault(); navigate('/validators/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/leaderboard') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -306,7 +306,7 @@ Leaderboard { e.preventDefault(); navigate('/validators/participants'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/participants') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -315,7 +315,7 @@ Participants { e.preventDefault(); navigate('/validators/wall-of-shame'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/wall-of-shame') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -324,7 +324,7 @@ Wall of Shame { e.preventDefault(); navigate('/validators/waitlist'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/waitlist') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -359,7 +359,7 @@ {#if !collapsed && getActiveSection() === 'community'}
{ e.preventDefault(); navigate('/community/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/contributions') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -368,7 +368,7 @@ Contributions { e.preventDefault(); navigate('/community/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/leaderboard') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -377,7 +377,7 @@ Leaderboard { e.preventDefault(); navigate('/community/poaps'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/poaps') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -413,7 +413,7 @@ {#if !collapsed && getActiveSection() === 'steward' && $userStore.user?.steward}
{ e.preventDefault(); navigate('/stewards/submissions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/submissions') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -423,7 +423,7 @@ {#if canAccessDiscordXP} { e.preventDefault(); navigate('/stewards/discord-xp'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/discord-xp') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -433,7 +433,7 @@ {/if} { e.preventDefault(); navigate('/stewards/manage-users'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/manage-users') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -628,7 +628,7 @@ {#if getActiveSection() === 'global'}
{ e.preventDefault(); navigate('/testnets'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/testnets') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -637,7 +637,7 @@ Testnets { e.preventDefault(); navigate('/metrics'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/metrics') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -670,7 +670,7 @@ {#if getActiveSection() === 'builder'}
{ e.preventDefault(); navigate('/builders/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/contributions') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -679,7 +679,7 @@ Contributions { e.preventDefault(); navigate('/builders/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/leaderboard') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -688,7 +688,7 @@ Leaderboard { e.preventDefault(); navigate('/builders/resources'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/builders/resources') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' @@ -716,7 +716,7 @@ {#if getActiveSection() === 'validator'}
{ e.preventDefault(); navigate('/validators/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/contributions') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -725,7 +725,7 @@ Contributions { e.preventDefault(); navigate('/validators/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/leaderboard') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -734,7 +734,7 @@ Leaderboard { e.preventDefault(); navigate('/validators/participants'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/participants') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -743,7 +743,7 @@ Participants { e.preventDefault(); navigate('/validators/waitlist'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/validators/waitlist') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' @@ -771,7 +771,7 @@ {#if getActiveSection() === 'community'}
{ e.preventDefault(); navigate('/community/contributions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/contributions') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -780,7 +780,7 @@ Contributions { e.preventDefault(); navigate('/community/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/leaderboard') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -789,7 +789,7 @@ Leaderboard { e.preventDefault(); navigate('/community/poaps'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/community/poaps') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' @@ -817,7 +817,7 @@ {#if getActiveSection() === 'steward' && $userStore.user?.steward}
{ e.preventDefault(); navigate('/stewards/submissions'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/submissions') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -827,7 +827,7 @@ {#if canAccessDiscordXP} { e.preventDefault(); navigate('/stewards/discord-xp'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/discord-xp') ? 'border-[#19A663]' : 'border-[#f5f5f5]' @@ -837,7 +837,7 @@ {/if} { e.preventDefault(); navigate('/stewards/manage-users'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { isActive('/stewards/manage-users') ? 'border-[#19A663]' : 'border-[#f5f5f5]' diff --git a/frontend/src/lib/normalizePath.js b/frontend/src/lib/normalizePath.js new file mode 100644 index 00000000..865c01d2 --- /dev/null +++ b/frontend/src/lib/normalizePath.js @@ -0,0 +1,48 @@ +/** + * Path-to-hash route normalization for the hash-based portal router. + * + * The portal uses `svelte-spa-router`, which resolves routes from the URL hash + * (e.g. `/#/testnets`). Several entry points still produce plain path URLs + * (e.g. `/testnets`, `/metrics`): sidebar `href` attributes, links opened in a + * new tab, refreshes of a path-based route, and shared/indexed links. When such + * a URL is opened directly, the hash is empty, the router has nothing to match, + * and the user lands on a 404 / NotFound view. + * + * This module decides whether a plain path URL should be rewritten into its + * hash equivalent so the router can resolve it. It is deliberately + * route-agnostic: any unknown path is forwarded to the router, which renders + * its own NotFound for genuinely missing routes. Static assets and known + * server-handled prefixes (OAuth, API) are left untouched. + */ + +const RESERVED_PREFIXES = ['/api', '/oauth', '/static', '/assets', '/media']; + +function looksLikeStaticFile(pathname) { + const lastSegment = pathname.split('/').pop() || ''; + return lastSegment.includes('.'); +} + +export function computeNormalizedUrl(location) { + const pathname = location.pathname || '/'; + const hash = location.hash || ''; + const search = location.search || ''; + + if (hash) return null; + if (pathname === '/' || pathname === '') return null; + if (RESERVED_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return null; + if (looksLikeStaticFile(pathname)) return null; + + return `/#${pathname}${search}`; +} + +export function normalizeLocation( + win = typeof window !== 'undefined' ? window : undefined +) { + if (!win || !win.location || !win.history) return false; + + const target = computeNormalizedUrl(win.location); + if (!target) return false; + + win.history.replaceState({}, '', target); + return true; +} diff --git a/frontend/src/tests/normalizePath.test.js b/frontend/src/tests/normalizePath.test.js new file mode 100644 index 00000000..5c1059a0 --- /dev/null +++ b/frontend/src/tests/normalizePath.test.js @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + computeNormalizedUrl, + normalizeLocation, +} from '../lib/normalizePath.js'; + +describe('computeNormalizedUrl', () => { + it('rewrites a plain path route into its hash equivalent', () => { + expect(computeNormalizedUrl({ pathname: '/testnets', hash: '', search: '' })) + .toBe('/#/testnets'); + expect(computeNormalizedUrl({ pathname: '/metrics', hash: '', search: '' })) + .toBe('/#/metrics'); + }); + + it('preserves the query string when rewriting', () => { + expect(computeNormalizedUrl({ pathname: '/metrics', hash: '', search: '?range=30d' })) + .toBe('/#/metrics?range=30d'); + }); + + it('rewrites nested path routes', () => { + expect(computeNormalizedUrl({ pathname: '/builders/leaderboard', hash: '', search: '' })) + .toBe('/#/builders/leaderboard'); + }); + + it('returns null when a hash is already present', () => { + expect(computeNormalizedUrl({ pathname: '/testnets', hash: '#/testnets', search: '' })) + .toBeNull(); + expect(computeNormalizedUrl({ pathname: '/', hash: '#/metrics', search: '' })) + .toBeNull(); + }); + + it('returns null for the root path', () => { + expect(computeNormalizedUrl({ pathname: '/', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '', hash: '', search: '' })).toBeNull(); + }); + + it('leaves reserved or server-handled prefixes untouched', () => { + expect(computeNormalizedUrl({ pathname: '/api/users', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/oauth/callback', hash: '', search: '?code=x' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/assets/app.js', hash: '', search: '' })).toBeNull(); + }); + + it('leaves static file requests untouched', () => { + expect(computeNormalizedUrl({ pathname: '/favicon.ico', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/robots.txt', hash: '', search: '' })).toBeNull(); + expect(computeNormalizedUrl({ pathname: '/sitemap.xml', hash: '', search: '' })).toBeNull(); + }); + + it('tolerates missing hash and search fields', () => { + expect(computeNormalizedUrl({ pathname: '/leaderboard' })).toBe('/#/leaderboard'); + }); +}); + +describe('normalizeLocation', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('rewrites the URL via history.replaceState when normalization is needed', () => { + const replaceState = vi.fn(); + const win = { + location: { pathname: '/testnets', hash: '', search: '' }, + history: { replaceState }, + }; + + const changed = normalizeLocation(win); + + expect(changed).toBe(true); + expect(replaceState).toHaveBeenCalledWith({}, '', '/#/testnets'); + }); + + it('does nothing when a hash route is already present', () => { + const replaceState = vi.fn(); + const win = { + location: { pathname: '/', hash: '#/testnets', search: '' }, + history: { replaceState }, + }; + + const changed = normalizeLocation(win); + + expect(changed).toBe(false); + expect(replaceState).not.toHaveBeenCalled(); + }); + + it('returns false safely when window or history is unavailable', () => { + expect(normalizeLocation(undefined)).toBe(false); + expect(normalizeLocation({})).toBe(false); + expect(normalizeLocation({ location: { pathname: '/testnets' } })).toBe(false); + }); +}); From 89c5b23af787ce3aa89fdc612d6d93fbaea63b03 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 8 Jun 2026 21:11:55 +0200 Subject: [PATCH 2/2] docs: document hash route normalization --- frontend/CLAUDE.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 063d910a..b96426bf 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -285,6 +285,7 @@ frontend/src/ - Handles tooltip positioning - Manages route changes - How it works page (`/how-it-works`) renders full-bleed (no `px-3 py-3` padding on `
`) + - Calls `normalizeLocation(window)` from `src/lib/normalizePath.js` during app startup to normalize direct/path-based URLs for the hash router - **Navigation**: `src/components/Navbar.svelte` - Top navigation bar - Auth button integration @@ -300,6 +301,13 @@ frontend/src/ - **How it works** (bottom pinned area, above Submit Contribution) - Links to `/how-it-works` - **Profile** - User profile and submissions +### Hash Route Normalization +The portal uses `svelte-spa-router`, so app routes must be represented as hash URLs such as `/#/testnets`. `src/App.svelte` imports `normalizeLocation` from `src/lib/normalizePath.js` and invokes `normalizeLocation(window)` once at initial app load. + +`normalizeLocation` reads `window.location.pathname`, `window.location.search`, and `window.location.hash`. If a hash is already present, the path is `/`, the path looks like a static file, or the path starts with a reserved server/static prefix (`/api`, `/oauth`, `/static`, `/assets`, `/media`), it does nothing. Otherwise it rewrites the current URL with `window.history.replaceState({}, '', '/#' + pathname + search)`, so `/metrics?range=30d` becomes `/#/metrics?range=30d`. + +Use this normalization when debugging direct links, copied links, refreshes, or server-served deep links that arrive as plain paths. New external entry points to SPA routes should either emit hash URLs directly (`/#/route`) or be normalized by invoking `normalizeLocation(window)` before the router resolves the page. + ### Routes/Pages All routes are defined in `src/App.svelte`: ```javascript @@ -807,4 +815,4 @@ $location // reactive store with current path - Pages go in `src/routes/` - Reusable components in `src/components/` - API functions in `src/lib/api.js` -- New API endpoints need both frontend (api.js) and backend implementation \ No newline at end of file +- New API endpoints need both frontend (api.js) and backend implementation