連結精算表のURLをクエリパラメータからパスベースルーティングに移行した
連結精算表ページのURLを ?topic=year1&mid=worksheet&sheet=worksheet-sum のようなクエリパラメータ方式から /consolidated-worksheet/year1/worksheet/worksheet-sum というパスベースに変更した。やったことを順に書く。
なぜパスベースにしたのか
クエリパラメータ方式には問題があった。
- SSG(静的サイト生成)でプリレンダーしにくい。クエリパラメータ付きのURLは静的ファイルとして生成できない
- URLを見ただけで何のページかわからない
- ブラウザの戻る/進むボタンで状態が復元されない(クエリパラメータの変更はVue Routerのpushと連動しづらい)
- SEO的にもパスベースのほうが有利
作業の流れ
1. ページファイルのリネーム
index.vue を [...slug].vue に変更した。Nuxt 3のcatch-allルートを使う。
apps/web/app/pages/consolidated-worksheet/
index.vue → [...slug].vue
[...slug].vue にすると、/consolidated-worksheet/year1/worksheet/worksheet-sum のようなURLで route.params.slug が ['year1', 'worksheet', 'worksheet-sum'] という配列になる。
2. parseSlug / buildPath 関数の実装
slug配列の解析とパス構築を担う2つの関数を routing.ts に切り出した。
// routing.ts
export function parseSlug(slug: string[]): SlugState {
const [topicRaw, midRaw, itemRaw] = slug
const ids = getTopicIds()
const topic = topicRaw && ids.includes(topicRaw) ? topicRaw : ids[0]
const mid = midRaw && validMids.includes(midRaw as MidCategory)
? midRaw as MidCategory
: 'prerequisites'
const ds = getDataset(topic)
if (mid === 'prerequisites') return { topic, mid, item: 'prerequisites' }
if (mid === 'consolidated-fs') return { topic, mid, item: 'consolidated-fs' }
if (mid === 'worksheet') {
const valid = ds.sheets.some(s => s.id === itemRaw)
return { topic, mid, item: valid ? itemRaw! : ds.sheets[0].id }
}
if (mid === 'journal') {
const valid = ds.journalCategories.some(c => c.id === itemRaw)
return { topic, mid, item: valid ? itemRaw! : ds.journalCategories[0].id }
}
// individual-fs
const valid = ds.individualCompanies.some(c => c.id === itemRaw)
return { topic, mid, item: valid ? itemRaw! : ds.individualCompanies[0].id }
}
export function buildPath(topic: string, mid: MidCategory, item: string): string {
const base = '/consolidated-worksheet'
if (mid === 'prerequisites') return `${base}/${topic}/prerequisites`
if (mid === 'consolidated-fs') return `${base}/${topic}/consolidated-fs`
return `${base}/${topic}/${mid}/${item}`
}
設計のポイント:
- 不正な値はすべてフォールバック: 存在しないtopicは
year1に、存在しないmidはprerequisitesに、存在しないitemは各カテゴリの先頭項目にフォールバックする - prerequisites と consolidated-fs は2階層パス: これらはitem(小カテゴリ)を持たないので
/consolidated-worksheet/year1/prerequisitesのような2階層パス - それ以外は3階層パス:
/consolidated-worksheet/{topic}/{mid}/{item}の形式
3. デフォルトリダイレクト
/consolidated-worksheet にアクセスした場合(slugが空の場合)は /consolidated-worksheet/year1/prerequisites にリダイレクトする。
function resolveInitialState() {
const rawSlug = route.params.slug
const slugArray = Array.isArray(rawSlug) ? rawSlug : (rawSlug ? [rawSlug] : [])
if (slugArray.length === 0) {
navigateTo('/consolidated-worksheet/year1/prerequisites', { replace: true })
return { topic: 'year1', mid: 'prerequisites', item: 'prerequisites' }
}
const state = parseSlug(slugArray)
return { ...state, highlight: (route.query.highlight as string) || undefined }
}
4. 旧URLからのリダイレクト互換性
旧形式のクエリパラメータ(?topic=year1&mid=worksheet&sheet=worksheet-sum)でアクセスされた場合も、対応するパスベースURLにリダイレクトする。parseSlugFromQuery 関数がクエリパラメータを SlugState に変換し、buildPath で新しいパスを生成して navigateTo でリダイレクトする。
if (route.query.topic || route.query.mid) {
const state = parseSlugFromQuery(route.query as Record<string, string | string[]>)
navigateTo(buildPath(state.topic, state.mid, state.item), { replace: true })
return state
}
5. SSGプリレンダールート自動生成
nuxt.config.ts の nitro:config フックで、dataset-registry のデータから動的にプリレンダールートを生成するようにした。
// nuxt.config.ts (nitro:config hook)
const consolidatedRoutes = getTopicIds().flatMap(topic => {
const ds = getDataset(topic)
return [
`/consolidated-worksheet/${topic}/prerequisites`,
...ds.sheets.map(s => `/consolidated-worksheet/${topic}/worksheet/${s.id}`),
...ds.journalCategories.map(c => `/consolidated-worksheet/${topic}/journal/${c.id}`),
...ds.individualCompanies.map(c => `/consolidated-worksheet/${topic}/individual-fs/${c.id}`),
]
})
nitroConfig.prerender.routes.push(...consolidatedRoutes);
新しいtopicやsheet/journal/companyをデータに追加すれば、プリレンダールートも自動的に増える。手動でルート一覧を管理する必要がない。
ブラウザバック問題の発見と修正
パスベースルーティングに移行した直後、ブラウザの戻る/進むボタンが正しく動かない問題が発生した。
症状
ユーザー操作(タブクリック、キーボード矢印)でURLは変わるが、ブラウザの「戻る」を押すと状態が戻らない。コンソールには以下のような警告が出ていた。
[Vue Router warn]: Detected a possibly infinite redirection in a navigation guard
原因
状態をURLに同期する処理が2つ同時に走っていた。
- syncToRoute: パス変更時に
router.pushを実行(履歴に追加) - syncHighlightOnly: highlight列が変わっただけのとき
router.replaceを実行(履歴を汚さない)
問題は、タブを切り替えると selectedMidCategory と highlightColumn(undefinedにリセット)が同時に変わること。watchが2回発火し、router.push と router.replace が同時に呼ばれる。Vue Routerは同一ティック内で2つのナビゲーションを受けると、先に呼ばれたpushをキャンセルしてしまう。結果、履歴にエントリが追加されず、戻るボタンが機能しなかった。
解決策
watchを統合し、「パス部分が変わったかどうか」で push と replace を使い分けるようにした。
watch(
[selectedTopicId, selectedMidCategory, selectedSheetId,
selectedJournalCategoryId, selectedCompanyId, highlightColumn],
([newTopic, newMid, newSheet, newJournal, newCompany],
[oldTopic, oldMid, oldSheet, oldJournal, oldCompany]) => {
if (!isHydrated || isNavigatingFromRoute) return
const path = buildPath(selectedTopicId.value, selectedMidCategory.value, selectedSmallId.value)
const query: Record<string, string> = {}
if (highlightColumn.value) query.highlight = highlightColumn.value
const target = path + (query.highlight ? `?highlight=${query.highlight}` : '')
if (route.fullPath === target) return
// パス部分に変更がなければreplaceで履歴を汚さない
const onlyHighlightChanged = newTopic === oldTopic && newMid === oldMid
&& newSheet === oldSheet && newJournal === oldJournal && newCompany === oldCompany
if (onlyHighlightChanged) {
router.replace({ path, query })
} else {
router.push({ path, query })
}
},
)
単一のwatchにまとめたことで、同一ティック内でpushとreplaceが競合する問題がなくなった。
popstate対応
ブラウザの戻る/進むで route.params.slug が変わったときに、状態を復元する処理も追加した。isNavigatingFromRoute フラグで、復元中に上のwatchが反応して無限ループになるのを防いでいる。
watch(() => route.params.slug, (newSlug) => {
if (!isHydrated) return
isNavigatingFromRoute = true
const state = parseSlug(Array.isArray(newSlug) ? newSlug : [])
selectedTopicId.value = state.topic
selectedMidCategory.value = state.mid
// ... 各refを復元
nextTick(() => { isNavigatingFromRoute = false })
})
テスト
ユニットテスト(40件)
routing.ts をコンポーネントから分離したことで、純粋関数としてテストできるようになった。
テストの構成:
consolidated-worksheet-routing.test.ts
├── parseSlug
│ ├── 空のslugはデフォルト値を返す
│ ├── 不正なtopicはyear1にフォールバック
│ ├── 不正なmidはprerequisitesにフォールバック
│ ├── 不正なitemは最初の有効項目にフォールバック
│ ├── 有効なslugはそのまま返す
│ ├── prerequisitesはitemを無視
│ └── consolidated-fsはitemを無視
├── buildPath
│ ├── prerequisitesは2階層パスを生成
│ ├── worksheet/journal/individual-fsは3階層パスを生成
│ └── consolidated-fsは2階層パスを生成
├── parseSlug ↔ buildPath ラウンドトリップ
│ └── 全topic × 全mid × 全item の組み合わせで
│ buildPath → パスをslugに変換 → parseSlug が元の状態を返すことを検証
└── parseSlugFromQuery
├── 旧形式のworksheetクエリを正しく変換
├── 旧形式のjournalクエリを正しく変換
├── 旧形式のindividual-fsクエリを正しく変換
└── 空のクエリはデフォルト値を返す
ラウンドトリップテストは year1 と year2 の全sheet/journal/companyを網羅的にテストしているので、件数が多い。parseSlugとbuildPathが互いに整合していることを保証できる。
Chrome DevTools MCPを使った手動テスト(7項目)
ユニットテストではカバーできないブラウザ上の動作を、Chrome DevTools MCPで確認した。
/consolidated-worksheet→/consolidated-worksheet/year1/prerequisitesにリダイレクトされる- 各タブをクリックしてURLが正しく変わる
- ブラウザの「戻る」で前の状態に戻る
- ブラウザの「進む」で次の状態に進む
- キーボードの左右矢印でナビゲーションでき、URLが変わり、履歴にも追加される
- 旧URL(クエリパラメータ形式)でアクセスすると、対応するパスベースURLにリダイレクトされる
- SSGビルドが成功し、全ルートが静的ファイルとして生成される
SSGビルド確認
pnpm build で全ルートが正常にプリレンダーされることを確認した。dataset-registry からの動的ルート生成により、topic追加時にビルド設定を変更する必要がない。
設計判断のまとめ
| 判断 | 理由 |
|---|---|
[...slug].vue catch-allルート | 可変深度のパス(2階層/3階層)を1つのページコンポーネントで処理するため |
routing.ts を別モジュールに分離 | テスト可能にするため。Vueコンポーネント内にロジックを閉じ込めるとvitestでテストしにくい |
parseSlugFromQuery で旧URL互換 | 既存のブックマークやリンクを壊さないため |
| watchの統合(push/replace判定) | 同一ティック内でpushとreplaceが競合するバグを防ぐため |
isNavigatingFromRoute フラグ | popstate → ref更新 → watch発火 → router操作 の無限ループ防止 |
| プリレンダールートをdataset-registryから生成 | データ追加時にnuxt.config.tsを手動で更新する必要をなくすため |
Codexレビューの指摘
Codex(OpenAI o3)にコードレビューを依頼し、以下の指摘を受けて計画を更新した。
routing.tsのモジュール分離とテストの追加を優先すべき- ラウンドトリップテスト(parseSlug → buildPath → parseSlug)の網羅性を上げる
- SSGビルドの検証を計画に含める
これらはすべて反映済み。