Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 09bf62e1c8efdccae41348823ec029e62fbe39f1
parent dcb54a161f8e17bacb5a2276d6948f3b93b4544b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 18 Aug 2026 19:02:28 -0400

umtool: come back to the page you were last on

The base URL was a dead end. app/page.tsx was four lines of
redirect("/sort"), so opening localhost:3050 dropped you in the sort pile
whatever you had been doing -- and because a server redirect fires before
any client code, nothing could ever have read where you were.

The whole URL is remembered, query included, so /browse/find?q=donut+lift
comes back with its search intact. The interesting part is not the storage
but where to land when the remembered URL has gone stale: a song gets
renamed, a cut gets rebuilt away. This app has no not-found.tsx, so a
stale restore would open the tool on Next's bare 404 -- no nav on it, no
way out but the back button. So a candidate walks UP the path,
/browse/yoshi/wide to /browse/yoshi to /browse to /sort, and each step is
probed with a HEAD against the REAL router. A local re-implementation of
"does this route exist" would drift the first time a route moved and would
have to re-derive precedence this app leans on (/mix beating [mode],
/browse/decisions inside [song] territory); asking the server costs one
request and cannot be wrong.

The same guard runs on write and on read. Storage is user-editable and
outlives any deploy, so a value read back is untrusted input -- a stored
//evil.example handed to router.replace() is an off-site redirect from a
URL you typed yourself.

No useSearchParams(), so no Suspense boundary: this app has none anywhere
on purpose and the root layout is the last place to introduce the first
one. window.location read inside an effect is the house pattern and gives
the query string for free.

Two things measured rather than assumed. HEAD returns truthful page
statuses here, so the probe need not be a GET re-render. And the noscript
refresh is written as raw HTML because React 19 hoists <meta> it parses
into <head> -- hoisted out of the noscript it would fire for everyone and
beat the restore to it.

The e2e seeding has a trap worth the comment it carries: the recorder also
writes on pagehide, so seeding by navigating away from a page overwrites
the seed with that page's own URL, and the stale-URL test would pass for
the wrong reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Diffstat:
Mumtool/app/layout.tsx | 8+++++++-
Mumtool/app/page.tsx | 21+++++++++++++++++++--
Aumtool/components/LastPage.tsx | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/last-page.spec.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/last-page.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 341 insertions(+), 3 deletions(-)

diff --git a/umtool/app/layout.tsx b/umtool/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; +import { LastPageRecorder } from "@/components/LastPage"; export const metadata: Metadata = { title: "um triage", @@ -12,7 +13,12 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> - <body className="h-full">{children}</body> + <body className="h-full"> + {/* Writes down where you are, so / can bring you back. Renders nothing, + and needs no Suspense boundary -- see components/LastPage.tsx. */} + <LastPageRecorder /> + {children} + </body> </html> ); } diff --git a/umtool/app/page.tsx b/umtool/app/page.tsx @@ -1,5 +1,22 @@ -import { redirect } from "next/navigation"; +import { LastPageRestore } from "@/components/LastPage"; +// The base URL is a doorway, not a page. It used to be redirect("/sort"), which +// dropped you in the sort pile whatever you were last doing -- and a server +// redirect fires before any client code, so nothing could ever read where you +// had been. The decision moves to the client; the redirect itself has not. export default function Home() { - redirect("/sort"); + return ( + <> + {/* With JS off there is no storage to read, so keep the old behaviour. + Written as raw HTML on purpose: React 19 hoists <meta> it parses into + <head>, and a refresh tag hoisted OUT of the <noscript> would fire for + everyone and beat the restore to it. */} + <noscript + dangerouslySetInnerHTML={{ + __html: '<meta http-equiv="refresh" content="0;url=/sort">', + }} + /> + <LastPageRestore /> + </> + ); } diff --git a/umtool/components/LastPage.tsx b/umtool/components/LastPage.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { DEFAULT_PAGE, LAST_PAGE_KEY, isRecordable, restoreCandidates } from "@/lib/last-page"; + +// Two halves of one behaviour: the recorder rides in the root layout and writes +// down where you are, and the restore renders at / and takes you back there. +// +// Neither uses useSearchParams(). That hook demands a Suspense boundary, and +// this app has none anywhere on purpose -- putting the first one in the root +// layout to satisfy a nav recorder would be the tail wagging the dog, with a +// real risk of pushing every route out of static rendering. window.location +// read inside an effect is the house pattern (Deck.tsx, MixBench.tsx, +// Waveform.tsx) and gives the query string for free. + +/** pathname + query, exactly as the address bar has it. */ +function here(): string { + return window.location.pathname + window.location.search; +} + +function remember() { + // Private mode, a full quota, storage disabled by policy: none of these are + // worth breaking a page over. Forgetting where you were is the cost. + try { + const url = here(); + if (isRecordable(url)) localStorage.setItem(LAST_PAGE_KEY, url); + } catch { + /* not remembering is not an error */ + } +} + +/** + * Mounted once, in the root layout. Renders nothing. + * + * usePathname() fires on every navigation, which covers moving between pages. + * It does NOT move when only the query changes -- /browse/find rewriting ?q= as + * you search stays one pathname -- so `pagehide` records too, which doubles as + * the honest capture of the URL you were on when you closed the tab. + */ +export function LastPageRecorder() { + const pathname = usePathname(); + useEffect(() => { + remember(); + window.addEventListener("pagehide", remember); + return () => window.removeEventListener("pagehide", remember); + }, [pathname]); + return null; +} + +/** + * Rendered by /. Finds the best live URL and replaces onto it. + * + * The candidates are probed against the REAL router with a HEAD, rather than + * checked against a list of routes rebuilt here. A local re-implementation + * would drift the first time a route moved, and it would have to re-derive + * precedence this app leans on -- /mix beating [mode], /browse/decisions + * sitting inside [song] territory. Asking the server costs one request and + * cannot be wrong. + * + * replace(), never push(): / is a doorway, and it should not sit in the back + * history of the page it opened. + */ +export function LastPageRestore() { + const router = useRouter(); + const [target, setTarget] = useState<string | null>(null); + + useEffect(() => { + let done = false; + + const go = (url: string) => { + if (done) return; + setTarget(url); + router.replace(url); + }; + + (async () => { + let stored: string | null = null; + try { + stored = localStorage.getItem(LAST_PAGE_KEY); + } catch { + stored = null; + } + // Nothing remembered, or something that is not a page here: behave + // exactly as the old redirect("/sort") did. + if (!stored || !isRecordable(stored)) { + go(DEFAULT_PAGE); + return; + } + + for (const url of restoreCandidates(stored)) { + if (done) return; + let alive = false; + try { + const res = await fetch(url, { method: "HEAD", cache: "no-store" }); + alive = res.status !== 404; + } catch { + alive = false; + } + if (alive) { + go(url); + return; + } + } + + // Every candidate down to /sort answered 404, which should not happen -- + // but a stored URL that can never resolve must not cost a probe on every + // open, so it goes. + try { + localStorage.removeItem(LAST_PAGE_KEY); + } catch { + /* nothing to clean up */ + } + go(DEFAULT_PAGE); + })(); + + return () => { + done = true; + }; + }, [router]); + + // A blank screen reads as broken; a slow probe should read as work. One line, + // no chrome -- this page is visible for a few hundred milliseconds. + return ( + <main className="flex h-full items-center justify-center p-4"> + <p className="micro" data-testid="last-page-restore"> + returning to {target ?? "where you were"} + </p> + </main> + ); +} diff --git a/umtool/e2e/last-page.spec.ts b/umtool/e2e/last-page.spec.ts @@ -0,0 +1,110 @@ +import { test, expect, type Page } from "@playwright/test"; +import { DEFAULT_PAGE, LAST_PAGE_KEY } from "../lib/last-page"; + +// / used to be redirect("/sort"). It now comes back to where you were, and +// these cover the four ways that can go wrong: nothing remembered, a URL with a +// query on it, a URL that has gone stale, and a URL somebody hand-edited into +// storage. +// +// The suite runs workers: 1 with NO state reset between tests, so a remembered +// URL leaks forward unless each test says what storage holds. Every test here +// does -- either by planting a value or by letting the recorder write its own. + +/** + * Plant (or clear) the remembered URL, applied ON THE DOORWAY DOCUMENT ONLY. + * + * The obvious version -- goto a page, setItem, goto "/" -- silently does not + * work, and passes for the wrong reason. The recorder also writes on + * `pagehide`, so the moment the test navigates to /, the page it is leaving + * overwrites the planted value with its own URL. A stale-URL test seeded that + * way would be testing that /browse restores to /browse. + * + * An init script runs at document start, which is after the old page's pagehide + * and before the restore effect reads storage -- so it is the one place a value + * can be planted and still be there when it is read. Guarded to `/` so the + * recorder stays free to do its real job on every other page. + */ +async function remembered(page: Page, value: string | null) { + await page.addInitScript( + ([key, v]) => { + if (location.pathname !== "/") return; + if (v === null) localStorage.removeItem(key); + else localStorage.setItem(key, v); + }, + [LAST_PAGE_KEY, value] as const, + ); +} + +/** Which nav item is lit -- the cheapest "where am I", as triage.spec uses. */ +function lit(page: Page, label: string) { + return page.getByRole("link", { name: label, exact: true }); +} + +test("with nothing remembered, / still lands on the sort pile", async ({ page }) => { + await remembered(page, null); + // Parked on /browse first, so a restore that read the wrong thing would land + // there and fail, instead of this passing because / happens to point at /sort. + await page.goto("/browse"); + await expect(lit(page, "browse")).toHaveAttribute("aria-current", "page"); + + await page.goto("/"); + + await expect(page).toHaveURL(new RegExp(`${DEFAULT_PAGE}$`)); + await expect(lit(page, "sort")).toHaveAttribute("aria-current", "page"); +}); + +test("/ comes back to the page you were last on", async ({ page }) => { + // No planted value: this is the recorder's OWN write, which is the thing + // actually shipping. + await page.goto("/browse/find"); + await expect(lit(page, "find")).toHaveAttribute("aria-current", "page"); + + await page.goto("/"); + + await expect(page).toHaveURL(/\/browse\/find$/); + await expect(lit(page, "find")).toHaveAttribute("aria-current", "page"); +}); + +test("the query string comes back with it", async ({ page }) => { + // The reason the whole URL is remembered rather than the pathname: a search + // you have to retype is not a page you came back to. + await page.goto("/sort?limit=5"); + await expect(lit(page, "sort")).toHaveAttribute("aria-current", "page"); + + await page.goto("/"); + + await expect(page).toHaveURL(/\/sort\?limit=5$/); +}); + +test("a remembered URL that has gone stale walks UP instead of 404ing", async ({ page }) => { + // A cut of a song that does not exist -- what a rename or a rebuild leaves + // behind. This app has no not-found.tsx, so landing on it would mean Next's + // bare 404: no nav on it, and no way out but the back button. It must degrade + // one level at a time to /browse. + await remembered(page, "/browse/__gone__/wide"); + + await page.goto("/"); + + await expect(page).toHaveURL(/\/browse$/); + await expect(lit(page, "browse")).toHaveAttribute("aria-current", "page"); + // The assertion that says "not a dead end": the 404 has no nav at all. + await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toBeVisible(); + + // And the URL really is dead -- otherwise this would pass for the wrong + // reason the day that route quietly started rendering something. + expect((await page.request.get("/browse/__gone__/wide")).status()).toBe(404); +}); + +for (const hostile of ["//evil.example", "https://evil.example", "/api/verdict", "/"]) { + test(`a stored ${hostile} is ignored, not navigated to`, async ({ page }) => { + // Storage is user-editable and outlives any deploy, so a value read back is + // untrusted input. Two of these leave the machine, one is not a page, and + // one is this page -- which would loop. + await remembered(page, hostile); + + await page.goto("/"); + + await expect(page).toHaveURL(new RegExp(`${DEFAULT_PAGE}$`)); + await expect(lit(page, "sort")).toHaveAttribute("aria-current", "page"); + }); +} diff --git a/umtool/lib/last-page.ts b/umtool/lib/last-page.ts @@ -0,0 +1,74 @@ +// Where you were last, as data. NO server imports -- LastPage.tsx is a client +// component and imports these as VALUES, so the same rule lib/note-types.ts +// spells out applies here: one `node:` import anywhere in this file's graph and +// the bundler follows it into the browser chunk and 500s every page. +// +// The interesting part of "come back to where I was" is not the storage, it is +// deciding WHERE to land when the remembered URL has gone stale -- a song gets +// renamed, a cut gets rebuilt away, and /browse/yoshi/wide stops existing. This +// app has no app/not-found.tsx, so a stale restore would open the tool on +// Next's bare 404, which has no nav on it and no way out but the back button. +// So the walk-up lives here, pure and testable without a browser. + +/** Same `umtool:` prefix as SHORTLIST_EVENT -- one namespace for the app. */ +export const LAST_PAGE_KEY = "umtool:last-page"; + +/** What / redirected to unconditionally before this existed, now the floor. */ +export const DEFAULT_PAGE = "/sort"; + +/** Long enough for any real query string, short enough to bound a bad write. */ +const URL_LIMIT = 2000; + +/** + * Is this a URL we would both record and restore to? + * + * Deliberately the SAME guard on both sides. Storage is user-editable and + * survives a reinstall of the app, so a value read back is untrusted input: a + * stored `//evil.example` handed to router.replace() is an off-site redirect + * from a URL the user typed themselves. Relative, same-origin, real page only. + */ +export function isRecordable(url: string): boolean { + if (!url || url.length > URL_LIMIT) return false; + if (!url.startsWith("/")) return false; + // `//host` is protocol-relative -- it looks local and is not. + if (url.startsWith("//")) return false; + // A backslash is a slash to some URL parsers and not to others; refuse the + // whole class rather than guess which one router.replace() feeds. + if (url.includes("\\")) return false; + // `/` itself is this very page. Restoring to it would be a loop. + if (url === "/") return false; + // The API is not a place you can be. + if (url === "/api" || url.startsWith("/api/")) return false; + return true; +} + +/** + * Where to try landing, best first. + * + * The full URL, then the same path without its query, then each parent, then + * DEFAULT_PAGE. `/browse/yoshi/wide` gives + * `["/browse/yoshi/wide", "/browse/yoshi", "/browse", "/sort"]` -- so a cut + * that no longer exists degrades to its song, a song that no longer exists + * degrades to the list of songs, and nothing ever lands on a 404. + */ +export function restoreCandidates(url: string): string[] { + const out: string[] = []; + const add = (u: string) => { + if (u && !out.includes(u)) out.push(u); + }; + + if (isRecordable(url)) { + add(url); + const q = url.search(/[?#]/); + let path = q === -1 ? url : url.slice(0, q); + add(path); + // Stop BEFORE "/": it is this page, and isRecordable rejects it anyway. + while (path.length > 1) { + path = path.slice(0, path.lastIndexOf("/")); + if (path.length > 1) add(path); + } + } + + add(DEFAULT_PAGE); + return out; +}