Skip to content

Branded 404 that redirects pre-redesign URLs - #3

Merged
dommango merged 3 commits into
mainfrom
feat/not-found-rescue
Aug 31, 2026
Merged

dommango merged 3 commits into
mainfrom
feat/not-found-rescue

Conversation

@dommango

Copy link
Copy Markdown
Owner

Summary

Implements plan 07 from the 2026-08-31 site audit (audit/2026-08-31-plans, not yet merged).

Pre-redesign URLs (/career, /skills, /education, /travel, /contact, /blog, /projects) currently render Next's default 404 on the live site, gold chat bubble and all. This adds a branded app/not-found.tsx: recognized old paths get a 3-second countdown then a client-side redirect to the matching in-page section, with a "Go there now" skip button and a "Stay on this page" cancel; unrecognized paths get a plain, no-guessing 404 with links to every section.

Ships as out/404.html via output: 'export', which GitHub Pages serves for any unknown path.

Notable deviations from the plan doc

  • Reads the pathname via useSyncExternalStore rather than useState+useEffect, and uses next/link for the two links targeting / — both required to satisfy this repo's eslint-config-next React Compiler lint rules (react-hooks/set-state-in-effect, @next/next/no-html-link-for-pages), which the plan's given code didn't pass as written.
  • Split into a server component (app/not-found.tsx, owns metadata — a 'use client' not-found page otherwise silently inherits the homepage's <title> and canonical URL) plus components/not-found/NotFoundClient.tsx for the interactive part.
  • /blog, /writing, /posts only redirect to /#writing once hasPosts() is true (the Writing section doesn't render, and has no anchor, while POSTS is empty) — otherwise they fall through to the plain 404, same as any other unrecognized path.
  • Added a "Stay on this page" control and a click-capture guard so the redirect can be cancelled (WCAG 2.2.1) and can't override a navigation the visitor already started.
  • Fixed a reflected-pathname overflow (a long/crafted URL could blow the page out past viewport width) and a dead CSS gap token in the section list.

A code-reviewer pass on the first draft caught the /blog dead-anchor and the overflow bug as HIGH findings; both are fixed and covered by tests.

Test plan

  • npx tsc --noEmit — clean
  • npm run lint — clean except two pre-existing errors unrelated to this change (TravelMap.tsx, vitest.setup.ts; tracked in plan 10)
  • npm test -- --run — 16/16 unit tests pass (13 existing + 3 new, table-driven over the redirect map)
  • npx playwright test — 25/25 e2e pass (18 existing + 5 new in e2e/not-found.spec.ts), plus 2 pre-existing suites unaffected
  • npm run build && ls out/404.html — static export produces the 404 shell
  • Manually confirmed the overflow fix (scrollWidth == clientWidth at 1280px for a 301-char path, vs. 2581px before the fix)
  • After merge: visit https://dommango.github.io/travel and confirm it lands on the map within 3 seconds

Pre-redesign URLs (/career, /travel, /contact, /blog, /projects) render
Next's default 404 today, verified live. This replaces it with a branded
page: recognized old paths show a 3-second countdown then redirect to the
matching section; unknown paths get a plain no-guessing 404.

Diverges from the plan's given code in two places to satisfy this repo's
eslint-config-next (React Compiler) rules: reads window.location via
useSyncExternalStore instead of useEffect+setState (avoids
react-hooks/set-state-in-effect), and uses next/link for the two links
that target "/" (avoids @next/next/no-html-link-for-pages). Behavior is
unchanged - static out/404.html still SSRs the generic copy first and
swaps to the redirect copy on client hydration, confirmed by the e2e
suite running real browser JS.
HIGH: /blog, /writing, /posts redirected to /#writing, which doesn't
exist while POSTS is empty (hasPosts() false) - visitors landed at the
top of the homepage with a dead #writing anchor. Gate the redirect
target and the .nf-map Writing link on hasPosts(), same predicate
BrutalistLanding/Nav use. Guarded by a new unit test.

HIGH: the reflected pathname had no length cap and .nf-lead had no
overflow-wrap, so a long/crafted URL blew the page out to 2x viewport
width. Cap displayed path length and add overflow-wrap.

MEDIUM: split app/not-found.tsx into a server component (owns
metadata - was silently inheriting the homepage's <title> and
canonical URL) rendering the interactive part from the new
components/not-found/NotFoundClient.tsx. Added a "Stay on this page"
control and an onClickCapture guard so the 3s auto-redirect can be
cancelled (WCAG 2.2.1) and can't override a navigation the visitor
already started by clicking a section link. Split the countdown's
aria-live announcement from the visual ticker so screen readers get
one announcement, not one per second. Fixed .nf-map's dead gap token
(.writing-all's own margin-top was overriding it) and removed CSS that
duplicated .section's existing spacing.

e2e: added a /blog fallback test, a redirect-cancellation race test,
a viewport assertion on the redirect target, and a comment on what
this suite does and doesn't prove about the static export.
Redirecting /blog to '/' with label "the front page" produced "It now
lives on the front page, under the front page." Exclude the Writing
entry from REDIRECTS entirely while hasPosts() is false instead, so
/blog falls through to the honest plain 404 (no redirect, no guessing)
until a real post ships and gives it somewhere to go.
@dommango
dommango merged commit d0f6eab into main Aug 31, 2026
1 check passed
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.

1 participant