fix(frontend): mobile navbar navigation + SSR hydration mismatch

Root cause of 'navbar mobile tak bisa pindah halaman': Next <Link>
client-side navigation is dead app-wide. A React hydration mismatch
(#418: 'server rendered text didn't match the client') is thrown by the
SSR-seeded live feeds — relative times (formatRelativeTime(e.edited_at) /
m.created_at) computed with Date.now() render slightly differently on
server vs client, which breaks the Next client router (router.push is a
no-op). The desktop NavRail worked only because it uses plain <a href>
(hard navigation bypasses the broken router).

Fixes:
- mobile-nav.tsx: use plain <a href> (NOT Next <Link>), identical to the
  working sidebar NavRail, so mobile nav always navigates regardless of
  router state ('ikuti cara kerja sidebar').
- Add suppressHydrationWarning to the SSR-seeded relative-time spans so
  server/client drift no longer throws #418 (EditHistory, LiveModerationFeed,
  messages/results + detail rows, recordings, TermGlossary,
  ChannelCultureGlossary, CategoryDrilldown).

Verified on non-prod :4024 @375px: Voice/Media/Search all navigate, no #418
in console. Plan: .hermes/plans/mobile-nav-hydration-fix.md
This commit is contained in:
asepharyana
2026-08-28 09:57:49 +07:00
parent 4d0bbed596
commit 14bd20f072
10 changed files with 110 additions and 12 deletions
+61
View File
@@ -0,0 +1,61 @@
# GMW — Fix mobile navbar "tidak bisa pindah halaman" (root cause)
## Context / symptom
- User (phone, mobile viewport <md): pressing the bottom mobile nav items does NOT
navigate ("Masih sama, walau sudah ditekan tidak pindah halaman").
- Previous fix (commits ae41f64e, 4d0bbed5) repositioned the chatbot FAB above the
nav and expanded nav to all 7 items — but the bug persisted.
## Root cause (verified live 2026-08-28)
Reproduced on the LIVE site (`imphnen.asepharyana.my.id`) with Playwright @375px:
1. **Next `<Link>` client-side navigation is dead app-wide.** Clicking a mobile nav
`<Link>` fires the click (event reaches document, `inLink=true`,
`defaultPrevented=false`) but **the URL never changes**. `window.next.router.push('/voice')`
also does nothing. The desktop NavRail navigates only because it uses a **plain
`<a href>`** (full browser navigation bypasses the broken router).
2. **A React hydration failure (#418 "server rendered text didn't match the client")**
is thrown on live pages — the only console error. Cause: relative-time text
(`formatRelativeTime(e.edited_at)` / `formatRelativeTime(m.created_at)` in the
SSR-seeded messages/edit-history feed uses `Date.now()`; server and client
render slightly different text → hydration mismatch → React re-renders, and the
Next client router ends up non-functional.
## Why desktop "worked" / mobile didn't
- `NavRail` = plain `<a href>`**hard** navigation (works).
- `MobileNav` = Next `<Link>`**client** navigation → dead router.
User's instruction "ikuti cara kerja sidebar" = make mobile nav behave like the
sidebar (plain anchors).
## Changes
### 1. `src/components/shell/mobile-nav.tsx`
Replace Next `<Link>` with a **plain `<a href>`** (mirrors NavRail). Keep:
`usePathname` for active-state styling + `scrollIntoView` for snap-to-active.
Drop the now-unused `Link` import (biome import-order — run biome check --write).
### 2. Hydration root cause — `formatRelativeTime` in SSR-seeded feeds
Add `suppressHydrationWarning` to the timestamp elements in the SSR-seeded
components that render live-relative text so server/client text drift no longer
throws #418:
- `src/components/EditHistory.tsx` (line ~111 `edited {formatRelativeTime(...)}`)
- `src/app/(dashboard)/messages/view.tsx` MessageRow channel/time (line ~580) +
semantic row (line ~364)
- `src/components/LiveModerationFeed.tsx` (line ~139)
- (recordings/view.tsx, TermGlossary, ChannelCultureGlossary, CategoryDrilldown,
chatbot — chatbot is client-after-mount; add where SSR-seeded.)
NOTE: `suppressHydrationWarning` is safe for these single-text spans; the values
are cosmetic and self-correct on the next interval/render.
## Verification (non-prod, NEVER 4017)
- `pnpm format` + `pnpm lint` (biome) + `pnpm build` clean.
- Smoke on :4024 (fresh `next dev`): mobile click on nav item actually navigates
(URL changes) AND no React #418 in console.
- After push: `gh run watch`, then live `imphnen.asepharyana.my.id` @375px: nav tap
navigates, console free of #418.
## Files
- services/frontend/src/components/shell/mobile-nav.tsx
- services/frontend/src/components/EditHistory.tsx
- services/frontend/src/app/(dashboard)/messages/view.tsx
- services/frontend/src/components/LiveModerationFeed.tsx
- (others only if #418 persists)
+10
View File
@@ -51,3 +51,13 @@ Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→
not top-level. not top-level.
- `GET /api/voice/status` now includes `activeSpeakers` (authoritative shared - `GET /api/voice/status` now includes `activeSpeakers` (authoritative shared
snapshot from `src/modules/voice/live-speaker.ts` on the backend). snapshot from `src/modules/voice/live-speaker.ts` on the backend).
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
@@ -360,7 +360,10 @@ export function MessagesView({
<span className="font-mono text-[10px] font-semibold text-signal"> <span className="font-mono text-[10px] font-semibold text-signal">
{(r.score * 100).toFixed(0)}% RELEVANCE {(r.score * 100).toFixed(0)}% RELEVANCE
</span> </span>
<span className="ml-auto font-mono text-[10px] text-ink-muted"> <span
className="ml-auto font-mono text-[10px] text-ink-muted"
suppressHydrationWarning
>
{formatRelativeTime(r.created_at)} {formatRelativeTime(r.created_at)}
</span> </span>
</div> </div>
@@ -576,7 +579,10 @@ function MessageDetail({
{m.server_nick && m.server_nick !== m.username && ( {m.server_nick && m.server_nick !== m.username && (
<div className="text-[11px] text-ink-muted">@{m.username}</div> <div className="text-[11px] text-ink-muted">@{m.username}</div>
)} )}
<div className="mono text-[0.65rem] text-ink-faint"> <div
className="mono text-[0.65rem] text-ink-faint"
suppressHydrationWarning
>
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)} {getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
</div> </div>
</div> </div>
@@ -752,7 +758,10 @@ function MessageRow({
<span className="font-mono text-[10px] text-ink-muted"> <span className="font-mono text-[10px] text-ink-muted">
{getMessageChannelLabel(m)} {getMessageChannelLabel(m)}
</span> </span>
<span className="ml-auto font-mono text-[10px] text-ink-muted"> <span
className="ml-auto font-mono text-[10px] text-ink-muted"
suppressHydrationWarning
>
{formatRelativeTime(m.created_at)} {formatRelativeTime(m.created_at)}
</span> </span>
</div> </div>
@@ -215,7 +215,9 @@ export function RecordingsView({
{r.channel_name ?? "voice"} {r.channel_name ?? "voice"}
</span> </span>
<span>·</span> <span>·</span>
<span>{formatRelativeTime(r.created_at)}</span> <span suppressHydrationWarning>
{formatRelativeTime(r.created_at)}
</span>
</div> </div>
</div> </div>
{isPlaying && <NowPlayingChip />} {isPlaying && <NowPlayingChip />}
@@ -103,7 +103,10 @@ export function CategoryDrilldown({
{a.username && ( {a.username && (
<span className="text-ink-soft">@{a.username}</span> <span className="text-ink-soft">@{a.username}</span>
)} )}
<span className="text-ink-faint mono text-xs"> <span
className="text-ink-faint mono text-xs"
suppressHydrationWarning
>
{a.created_at ? formatRelativeTime(a.created_at) : ""} {a.created_at ? formatRelativeTime(a.created_at) : ""}
</span> </span>
</div> </div>
@@ -158,7 +158,7 @@ export function ChannelCultureGlossary({
<Activity className="size-3 text-signal" /> <Activity className="size-3 text-signal" />
INTEL RATIO INTEL RATIO
</span> </span>
<span> <span suppressHydrationWarning>
{c.last_analyzed_at {c.last_analyzed_at
? formatRelativeTime(c.last_analyzed_at) ? formatRelativeTime(c.last_analyzed_at)
: "NEVER"} : "NEVER"}
@@ -106,7 +106,10 @@ export function EditHistory({ edits }: { edits: EditHistoryRow[] }) {
<span className="text-xs font-semibold text-ink"> <span className="text-xs font-semibold text-ink">
{e.username ?? "unknown"} {e.username ?? "unknown"}
</span> </span>
<span className="flex items-center gap-1 text-[10px] text-ink-muted"> <span
className="flex items-center gap-1 text-[10px] text-ink-muted"
suppressHydrationWarning
>
<History className="size-3 text-ink-faint/50" /> <History className="size-3 text-ink-faint/50" />
edited {formatRelativeTime(e.edited_at)} edited {formatRelativeTime(e.edited_at)}
</span> </span>
@@ -135,7 +135,10 @@ export function LiveModerationFeed({
[{a.categories.slice(0, 2).join(", ")}] [{a.categories.slice(0, 2).join(", ")}]
</span> </span>
) : null} ) : null}
<span className="ml-auto font-mono text-[10px] text-ink-faint"> <span
className="ml-auto font-mono text-[10px] text-ink-faint"
suppressHydrationWarning
>
{formatRelativeTime(a.created_at)} {formatRelativeTime(a.created_at)}
</span> </span>
</div> </div>
@@ -30,7 +30,9 @@ function GlossaryEntry({ t }: { t: GlossaryRow }) {
</Badge> </Badge>
</div> </div>
<div className="flex shrink-0 items-center gap-2.5 font-mono text-[10px] text-ink-faint"> <div className="flex shrink-0 items-center gap-2.5 font-mono text-[10px] text-ink-faint">
<span>{formatRelativeTime(t.resolved_at)}</span> <span suppressHydrationWarning>
{formatRelativeTime(t.resolved_at)}
</span>
<ChevronDown <ChevronDown
className={`size-3.5 text-ink-muted transition-transform duration-200 ${open ? "rotate-180 text-ink" : ""}`} className={`size-3.5 text-ink-muted transition-transform duration-200 ${open ? "rotate-180 text-ink" : ""}`}
/> />
@@ -1,6 +1,5 @@
"use client"; "use client";
import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { isActivePath, mobileNavItems } from "@/lib/navigation"; import { isActivePath, mobileNavItems } from "@/lib/navigation";
@@ -12,6 +11,12 @@ import { cn } from "@/lib/utils";
* every page is reachable on mobile. On narrow screens the bar scrolls * every page is reachable on mobile. On narrow screens the bar scrolls
* horizontally (snap) the active item snaps into view on navigation. Safe-area * horizontally (snap) the active item snaps into view on navigation. Safe-area
* aware for notched devices. * aware for notched devices.
*
* NOTE: uses a plain `<a href>` (NOT Next `<Link>`) deliberately identical to
* the working NavRail. Next's client-side router is unreliable here (a hydration
* mismatch in the SSR-seeded live feeds leaves `router.push` a no-op), so client
* `<Link>` navigation dead-ends (the "navbar mobile tak bisa pindah halaman"
* bug). A plain anchor does a full browser navigation and always works.
*/ */
export function MobileNav() { export function MobileNav() {
const path = usePathname() ?? "/"; const path = usePathname() ?? "/";
@@ -39,7 +44,7 @@ export function MobileNav() {
{mobileNavItems.map((item) => { {mobileNavItems.map((item) => {
const active = isActivePath(path, item.matchPrefix); const active = isActivePath(path, item.matchPrefix);
return ( return (
<Link <a
key={item.href} key={item.href}
href={item.href} href={item.href}
aria-label={item.label} aria-label={item.label}
@@ -67,7 +72,7 @@ export function MobileNav() {
<span className="relative z-10 mt-0.5 max-w-full truncate text-[10px] leading-tight sm:text-xs"> <span className="relative z-10 mt-0.5 max-w-full truncate text-[10px] leading-tight sm:text-xs">
{item.label} {item.label}
</span> </span>
</Link> </a>
); );
})} })}
</div> </div>