startsWithの前方一致だと、navの親子メニューが同時に点灯する
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
サイドナビのactive判定を前方一致(pathname.startsWith(href + "/"))だけで書くと、祖先と子孫の関係にあるnav項目のhrefは互いにこの条件を満たすため、子ページを見ているときに親メニューまで同時にactiveになる。
結論
サイドナビのactive判定をpathname === href || pathname.startsWith(href + "/")という前方一致で書くと、祖先・子孫の関係にあるnav項目のhrefは互いにこの条件を満たしてしまう。そのため子ページを見ているのに、親メニューまで同時にactive表示(点灯)される。直し方は、一致した候補のうちhrefが最も長い(=最も具体的な)1件だけを選ぶ最長一致に切り替えること。
症状
管理画面のサイドナビで、学校管理者ロールが子ページ/admin/school/members(教職員)を開いたとき、親の/admin/school(学校管理)のナビ項目まで同時にactive表示されるとユーザーから報告された。
修正前のSidebar.tsxのactive判定は次のようになっていた。
// Sidebar.tsx:38-40(修正前・7d07011e時点)
{items.map((item) => {
// 完全一致 or 配下 (例: /admin/editor/123) を active 扱いにする。
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
itemsはnavItemsForRoleから渡されるnav項目の配列で、学校管理者向けには/admin/school(学校管理)と/admin/school/members(教職員)のように、親子関係にあるhrefが並んで含まれていた。
原因
items.mapはnav項目を1件ずつ独立に評価する。pathnameが/admin/school/membersのとき、それぞれの項目の判定結果は次のようになる。
/admin/schoolという項目について:pathname.startsWith("/admin/school/")が成立 → true(子ページを見ているので一致)/admin/school/membersという項目について:pathname === item.hrefが成立 → true(完全一致)
どちらの判定も、単独で見れば間違っていない。/admin/schoolの配下に/admin/school/membersがいるのは事実であり、前方一致はその事実を正しく検出している。問題は、「一致した候補が複数あるとき、そのうち最も具体的な1件だけを残す」という仕組みがどこにも無かったことで、両方の項目が同時にtrueになった時点でそのまま両方がactive表示に反映されていた。
直し方
apps/web/lib/nav.tsに、nav項目の配列から最長一致する1件のhrefだけを返す純粋関数activeNavHrefを切り出した。
// nav.ts:143-152(修正後・bbb68be2時点)
export function activeNavHref(items: readonly NavItem[], pathname: string): string {
let best = "";
for (const item of items) {
const matched = pathname === item.href || pathname.startsWith(`${item.href}/`);
if (matched && item.href.length > best.length) {
best = item.href;
}
}
return best;
}
判定の種類(完全一致 or 配下)自体は変えていない。変えたのは、一致した候補を全件走査しながらitem.href.length > best.lengthでhref文字列が最長の1件だけをbestとして残す点だ。/admin/schoolと/admin/school/membersはどちらもpathname /admin/school/membersに一致するが、後者のほうがhrefの文字列として長いのでbestになり、前者は残らない。一致が無い場合は空文字""を返す。
Sidebar.tsx側は、この関数が返したhrefと各項目のhrefを等価比較するだけになった。
// Sidebar.tsx:16-42(修正後・bbb68be2時点)
export function Sidebar({ items }: { items: readonly NavItem[] }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
// 最長一致のみを active にする(親 /admin/school が子 /admin/school/members で誤点灯するのを防ぐ)。
const activeHref = activeNavHref(items, pathname);
return (
<div className="admin-sidebar-wrap">
{/* ...ハンバーガーボタン等は省略... */}
<nav id="admin-nav" className="admin-nav" aria-label="メインナビゲーション">
<ul>
{items.map((item) => {
// 完全一致 or 配下 (例: /admin/editor/123) のうち最長一致のみ active(activeNavHref)。
const isActive = item.href === activeHref;
// ...
})}
</ul>
</nav>
</div>
);
}
items.map内の判定が「この項目は単独でpathnameに前方一致するか」から「この項目のhrefは、全項目の中で最長一致したhrefと同じか」に変わった。これにより、祖先と子孫が同時にactiveになる余地が構造として無くなった。
再発防止
activeNavHrefはnav.tsの純粋関数として切り出されており、DOMのレンダリングやNext.jsのルーティングから独立してテストできる。nav.test.tsには、このバグそのものを固定する回帰テストを含む5つのケースが追加された。
// nav.test.ts:211-239(bbb68be2時点)
describe("activeNavHref (最長一致)", () => {
const schoolAdmin = navItemsForRole("school_admin");
it("子ページ /admin/school/members では教職員のみ active、親の学校管理は点灯しない (バグ回帰)", () => {
// 旧実装は startsWith で親 /admin/school も一致し「学校管理」「教職員」が両点灯していた。
expect(activeNavHref(schoolAdmin, "/admin/school/members")).toBe("/admin/school/members");
});
it("親ページ /admin/school では学校管理が active", () => {
expect(activeNavHref(schoolAdmin, "/admin/school")).toBe("/admin/school");
});
it("配下ページ /admin/editor/123 はエディタ (親 href) を active にする", () => {
expect(activeNavHref(schoolAdmin, "/admin/editor/123")).toBe("/admin/editor");
});
it("どの項目にも一致しないパスは空文字(どれも active にしない)", () => {
expect(activeNavHref(schoolAdmin, "/admin/unknown")).toBe("");
expect(activeNavHref([], "/admin/school")).toBe("");
});
it("system_admin: /admin/system/schools/new は学校一覧を active(最長一致で 1 つだけ)", () => {
const sysAdmin = navItemsForRole("system_admin");
const active = activeNavHref(sysAdmin, "/admin/system/schools/new");
expect(active).toBe("/admin/system/schools");
// 他項目(教職員管理 /admin/system/users 等)は active にならない。
expect(active).not.toBe("/admin/system/users");
});
});
子のみactive・親のみactive・配下ページでは親hrefがactiveになる挙動の維持・不一致時は空文字・ロールが異なるnav構成での最長一致、という5パターンがこれで固定されている。元のコミットメッセージには、biome・typecheckとあわせてnav.testが26件とも緑だったことが記録されている。
よくある質問
Q1この問題はNext.jsやReact特有ですか?
判定ロジック自体はusePathnameで取得した文字列とhrefを比較するだけの、フレームワークに依存しないプレーンな文字列処理です。nav項目を配列で保持し、各項目を独立にstartsWithで前方一致判定する構造であれば、同じ事故は他のフレームワークでも起こります。
Q2最長一致に変えれば、もうこの種の誤点灯は起きませんか?
根拠にあるテストでは、子のみactive・親のみactive・配下ページ(エディタ)でも親hrefがactiveになる挙動の維持・どの項目にも一致しない場合は空文字・ロールによってnav項目の構成が変わる場合の最長一致、という5パターンを固定しています。これらの配置では解消されますが、それ以外の配置まで検証したとは記事の根拠からは言えません。
Q3修正はSidebar.tsxの中だけで直したのですか?
いいえ。判定ロジック自体はSidebar.tsx(UIコンポーネント)から、nav.tsの純粋関数activeNavHrefへ切り出されています。DOMやルーティングに依存しない形になったため、nav.test.tsでレンダリングを介さずに判定結果だけを検証できるようになりました。
確認した環境
- Next.js ^16.4.2 / React ~19.2.9 / TypeScript ^6.0.3 / vitest ^4.1.12
- 2026-06-06 に本番でユーザー報告・同日修正(PR #638)
この記事の根拠
- TypeScriptファイル 38〜40行目コミット 7d07011
- TypeScriptファイル 16〜42行目コミット bbb68be
- TypeScriptファイル 135〜152行目コミット bbb68be
- TypeScriptファイル 211〜239行目コミット bbb68be
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。