• #Nuxt3
  • #Vue Router
  • #SSG
  • #連結会計
  • #ルーティング
開発mdx-playgroundメモ

連結精算表の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.tsnitro: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つ同時に走っていた。

  1. syncToRoute: パス変更時に router.push を実行(履歴に追加)
  2. syncHighlightOnly: highlight列が変わっただけのとき router.replace を実行(履歴を汚さない)

問題は、タブを切り替えると selectedMidCategoryhighlightColumn(undefinedにリセット)が同時に変わること。watchが2回発火し、router.pushrouter.replace が同時に呼ばれる。Vue Routerは同一ティック内で2つのナビゲーションを受けると、先に呼ばれたpushをキャンセルしてしまう。結果、履歴にエントリが追加されず、戻るボタンが機能しなかった。

解決策

watchを統合し、「パス部分が変わったかどうか」で pushreplace を使い分けるようにした。

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クエリを正しく変換
    └── 空のクエリはデフォルト値を返す

ラウンドトリップテストは year1year2 の全sheet/journal/companyを網羅的にテストしているので、件数が多い。parseSlugとbuildPathが互いに整合していることを保証できる。

Chrome DevTools MCPを使った手動テスト(7項目)

ユニットテストではカバーできないブラウザ上の動作を、Chrome DevTools MCPで確認した。

  1. /consolidated-worksheet/consolidated-worksheet/year1/prerequisites にリダイレクトされる
  2. 各タブをクリックしてURLが正しく変わる
  3. ブラウザの「戻る」で前の状態に戻る
  4. ブラウザの「進む」で次の状態に進む
  5. キーボードの左右矢印でナビゲーションでき、URLが変わり、履歴にも追加される
  6. 旧URL(クエリパラメータ形式)でアクセスすると、対応するパスベースURLにリダイレクトされる
  7. 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ビルドの検証を計画に含める

これらはすべて反映済み。