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:
@@ -51,3 +51,13 @@ Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→
|
||||
not top-level.
|
||||
- `GET /api/voice/status` now includes `activeSpeakers` (authoritative shared
|
||||
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">
|
||||
{(r.score * 100).toFixed(0)}% RELEVANCE
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -576,7 +579,10 @@ function MessageDetail({
|
||||
{m.server_nick && m.server_nick !== m.username && (
|
||||
<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)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -752,7 +758,10 @@ function MessageRow({
|
||||
<span className="font-mono text-[10px] text-ink-muted">
|
||||
{getMessageChannelLabel(m)}
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -215,7 +215,9 @@ export function RecordingsView({
|
||||
{r.channel_name ?? "voice"}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelativeTime(r.created_at)}</span>
|
||||
<span suppressHydrationWarning>
|
||||
{formatRelativeTime(r.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isPlaying && <NowPlayingChip />}
|
||||
|
||||
@@ -103,7 +103,10 @@ export function CategoryDrilldown({
|
||||
{a.username && (
|
||||
<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) : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -158,7 +158,7 @@ export function ChannelCultureGlossary({
|
||||
<Activity className="size-3 text-signal" />
|
||||
INTEL RATIO
|
||||
</span>
|
||||
<span>
|
||||
<span suppressHydrationWarning>
|
||||
{c.last_analyzed_at
|
||||
? formatRelativeTime(c.last_analyzed_at)
|
||||
: "NEVER"}
|
||||
|
||||
@@ -106,7 +106,10 @@ export function EditHistory({ edits }: { edits: EditHistoryRow[] }) {
|
||||
<span className="text-xs font-semibold text-ink">
|
||||
{e.username ?? "unknown"}
|
||||
</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" />
|
||||
edited {formatRelativeTime(e.edited_at)}
|
||||
</span>
|
||||
|
||||
@@ -135,7 +135,10 @@ export function LiveModerationFeed({
|
||||
[{a.categories.slice(0, 2).join(", ")}]
|
||||
</span>
|
||||
) : 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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,9 @@ function GlossaryEntry({ t }: { t: GlossaryRow }) {
|
||||
</Badge>
|
||||
</div>
|
||||
<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
|
||||
className={`size-3.5 text-ink-muted transition-transform duration-200 ${open ? "rotate-180 text-ink" : ""}`}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
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
|
||||
* horizontally (snap) — the active item snaps into view on navigation. Safe-area
|
||||
* 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() {
|
||||
const path = usePathname() ?? "/";
|
||||
@@ -39,7 +44,7 @@ export function MobileNav() {
|
||||
{mobileNavItems.map((item) => {
|
||||
const active = isActivePath(path, item.matchPrefix);
|
||||
return (
|
||||
<Link
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
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">
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user