From 23c7cf8689ef23c974482e2894a615f32422c96f Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 14 Jun 2026 08:43:59 +0000 Subject: [PATCH 1/3] perf(workbooks): skip heavy fetches for anonymous users docs(workbooks): add Phase 2 plan for anonymous early return Co-Authored-By: Claude Sonnet 4.6 --- .../workbooks-anonymous-early-return/plan.md | 106 ++++++++++++++++++ src/routes/workbooks/+page.server.ts | 31 +++-- 2 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 docs/dev-notes/2026-06-14/workbooks-anonymous-early-return/plan.md diff --git a/docs/dev-notes/2026-06-14/workbooks-anonymous-early-return/plan.md b/docs/dev-notes/2026-06-14/workbooks-anonymous-early-return/plan.md new file mode 100644 index 000000000..6a394401b --- /dev/null +++ b/docs/dev-notes/2026-06-14/workbooks-anonymous-early-return/plan.md @@ -0,0 +1,106 @@ +# Phase 2:workbooks load の匿名早期 return + +> 親プラン:[docs/dev-notes/2026-06-13/sveltekit-caching/plan.md](../../2026-06-13/sveltekit-caching/plan.md) の Phase 2。 + +## Context(なぜやるか) + +Vercel の Function Duration / Fast Origin Transfer が直近で約1.5倍に増加。`/workbooks` の +load は**匿名アクセスでも重い問題集フェッチを毎回実行**している。一方 +[+page.svelte:104-160](../../../../src/routes/workbooks/+page.svelte#L104-L160) のタブ中身は丸ごと +`{#if loggedInUser}` で囲まれており、**匿名ユーザーには何も表示されない**。 + +つまり匿名(bot / クローラー含む)に対し「取得してゼロ表示」のムダが発生している。 +Phase 1(参照タスクのみ取得)完了後、匿名で残る重いフェッチは Promise.all 内の以下のみ: + +- `fetchWorkbooksByTab` → `getWorkbooksByPlacement` / `getWorkBooksCreatedByUsers`(本番 ≈0.28–0.55 MB) +- `getAvailableSolutionCategories`(SOLUTION タブ) +- `getSolutionCategoryMapByWorkbookId`(SOLUTION ALL) + +本 Phase はこれらを匿名時に丸ごとスキップする。**表示は不変**(匿名は従来どおり空ページ)。 + +## 方針(確定済み) + +- **CDN キャッシュ(s-maxage)は付けない** — fetch スキップのみに限定。匿名 CDN キャッシュは + problems 向け Phase 3 と同型で別途扱う。invocation は安価($0.60)で早期 return により + Duration は既に最小化されるため、本 Phase での追加価値は小さい。 +- **匿名の挙動は現状維持** — 空ページを返す(`/login` リダイレクトはしない)。SEO 発見性と + 親プランの「行儀の良い bot を安く受け入れる」方針を維持するため。 +- **テストは別チケット** — route load テストは repo に前例がなく、Phase 1 の残 TODO + (`fetches only tasks referenced by displayed workbooks` の load 統合テスト)と同じ mock 基盤を + 要する。両者をまとめて別タスクで起票し、本 Phase は実装+手動検証で完了とする。 + +## 変更内容 + +### 対象ファイル + +- [src/routes/workbooks/+page.server.ts](../../../../src/routes/workbooks/+page.server.ts)(唯一の変更ファイル) + +### 1. 匿名早期 return の追加 + +`selectedGrade` / `selectedCategory` をパースした直後(現 +page.server.ts:60 の後、`adminUser` 計算と try ブロックの前)に挿入する。 + +- `CREATED_BY_USER` リダイレクト(+page.server.ts:52-57)は**早期 return より前**のまま維持 + (匿名が `CREATED_BY_USER` タブに来たら従来どおり `/workbooks` へリダイレクト)。 +- 返却 shape は**ログイン時と同じキーをすべて保持**し、値だけ空にする。 + `+page.svelte` の `$effect`(sessionStorage、`data.tab`/`selectedGrade`/`selectedCategory` を参照)と + `$derived`(`data.workbooks` / 各 Map を参照)が壊れないため。 + +```typescript +// Phase 2: All tab content is gated by {#if loggedInUser}, so anonymous users +// render nothing. Skip the heavy workbook/category fetches and return a +// display-equivalent empty shape (keys preserved for the page's $effect/$derived). +if (!loggedInUser) { + return { + workbooks: [], + availableCategories: [], + solutionCategoryMap: new Map(), + tasksMapByIds: new Map(), + taskResultsByTaskId: new Map(), + loggedInUser: null, + tab, + selectedGrade, + selectedCategory, + }; +} +``` + +### 2. 早期 return により冗長化したガードの除去(複雑度の削減) + +早期 return 以降、`loggedInUser` は非 null が保証される。以下の null ガードは +**到達不能(dead)** になるため、可読性向上のため除去する(「ここで null になり得るか?」の曖昧さを消す): + +- +page.server.ts:73-75 + `loggedInUser ? getTaskResultsOnlyResultExists(loggedInUser.id, true) : Promise.resolve(new Map())` + → `getTaskResultsOnlyResultExists(loggedInUser.id, true)` +- +page.server.ts:82 + `tab === WorkBookTab.CURRICULUM && loggedInUser ? buildTaskIdsFromWorkbooks(workbooks) : []` + → `tab === WorkBookTab.CURRICULUM ? buildTaskIdsFromWorkbooks(workbooks) : []` +- `adminUser`(+page.server.ts:61)の `loggedInUser && isAdmin(...)` も `isAdmin(...)` に簡約可 + (`!!adminUser` は `adminUser` のままで可)。併せて +page.server.ts:78-80 付近の + 「非 CURRICULUM・匿名で空 Map」を説明するコメントも、匿名分岐が消えたため文面を更新する。 + +> 注:この除去は本 Phase の早期 return が直接生むクリーンアップであり、スコープ内。 +> Phase 1 のロジック自体(参照タスク取得)は変えない。 + +## 検証(手動) + +テスト自動化は別チケットのため、本 Phase はローカル手動検証で完了確認する。 + +1. `pnpm dev` で起動。 +2. **匿名**(セッション cookie なし / シークレットウィンドウ)で `/workbooks` にアクセス: + - 見出し `問題集` のみ表示・空タブ(エラーなし=従来どおり)。 + - DevTools の SSR レスポンス(document)ペイロードが大幅に縮小していること(問題集データが載っていない)。 + - 一時ログ or ブレークポイントで `getWorkbooksByPlacement` / `getWorkBooksCreatedByUsers` / + `getAvailableSolutionCategories` / `getSolutionCategoryMapByWorkbookId` が**呼ばれない**ことを確認。 +3. **ログイン**で `/workbooks` の各タブ(CURRICULUM / SOLUTION / ユーザ作成)にアクセス: + - 従来どおり問題集一覧・grade modes・解答状況が表示される(回帰なし)。 + - `?category=` 付き SOLUTION ALL でも `solutionCategoryMap` が機能する。 +4. **匿名 + `?tab=created_by_user`**:`/workbooks` へリダイレクトされる(既存挙動の維持)。 +5. `pnpm check`(型)と `pnpm lint` が通ること。`pnpm test:unit` が既存分グリーンであること。 + +## 残タスク(後続 TODO へ引き継ぎ) + +- [ ] **route load 統合テスト基盤**を別チケットで起票し、Phase 1 残 TODO と統合: + - `anonymous request skips heavy fetches`(service mock で匿名時の未呼び出しをアサート) + - `fetches only tasks referenced by displayed workbooks`(Phase 1 残) +- [ ] 本番相当データ(問題集150件規模)で payload 再計測 → Phase 5 要否判断の材料に。 diff --git a/src/routes/workbooks/+page.server.ts b/src/routes/workbooks/+page.server.ts index a17955ad7..52677d06c 100644 --- a/src/routes/workbooks/+page.server.ts +++ b/src/routes/workbooks/+page.server.ts @@ -58,7 +58,25 @@ export async function load({ locals, url }) { const selectedGrade = parseWorkBookGrade(params); const selectedCategory = parseWorkBookCategory(params); - const adminUser = loggedInUser && isAdmin(loggedInUser.role as Roles); + + // Phase 2: All tab content is gated by {#if loggedInUser}, so anonymous users + // render nothing. Skip the heavy workbook/category fetches and return a + // display-equivalent empty shape (keys preserved for the page's $effect/$derived). + if (!loggedInUser) { + return { + workbooks: [], + availableCategories: [], + solutionCategoryMap: new Map(), + tasksMapByIds: new Map(), + taskResultsByTaskId: new Map(), + loggedInUser: null, + tab, + selectedGrade, + selectedCategory, + }; + } + + const adminUser = isAdmin(loggedInUser.role as Roles); try { const [workbooks, availableCategories, solutionCategoryMap, taskResultsByTaskId] = @@ -70,16 +88,13 @@ export async function load({ locals, url }) { tab === WorkBookTab.SOLUTION && selectedCategory === ALL_SOLUTION_CATEGORIES ? getSolutionCategoryMapByWorkbookId(!!adminUser) : Promise.resolve(new Map()), - loggedInUser - ? taskResultsCrud.getTaskResultsOnlyResultExists(loggedInUser.id, true) - : Promise.resolve(new Map()), + taskResultsCrud.getTaskResultsOnlyResultExists(loggedInUser.id, true), ]); - // Grade modes are only displayed on the CURRICULUM tab for logged-in users. - // For other tabs / anonymous, the id list is empty and getTasksWithSelectedTaskIds - // returns [] without a query (see tasks.ts guard), so tasksMapByIds becomes an empty Map. + // Grade modes are only displayed on the CURRICULUM tab. + // For other tabs the id list is empty so tasksMapByIds becomes an empty Map. const referencedTaskIds = - tab === WorkBookTab.CURRICULUM && loggedInUser ? buildTaskIdsFromWorkbooks(workbooks) : []; + tab === WorkBookTab.CURRICULUM ? buildTaskIdsFromWorkbooks(workbooks) : []; const referencedTasks = await taskCrud.getTasksWithSelectedTaskIds(referencedTaskIds); const tasksMapByIds = new Map( referencedTasks.map((task) => [task.task_id, { grade: task.grade }]), From 6a19b801eef92c89c182e427c80a1984769bff8f Mon Sep 17 00:00:00 2001 From: "k.hiro1818" Date: Sun, 14 Jun 2026 08:52:38 +0000 Subject: [PATCH 2/3] refactor(workbooks): type Map generics and document anonymous-skip invariant Co-Authored-By: Claude Sonnet 4.6 --- src/routes/workbooks/+page.server.ts | 9 +++++---- src/routes/workbooks/+page.svelte | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/routes/workbooks/+page.server.ts b/src/routes/workbooks/+page.server.ts index 52677d06c..7bf04c196 100644 --- a/src/routes/workbooks/+page.server.ts +++ b/src/routes/workbooks/+page.server.ts @@ -6,6 +6,7 @@ import * as taskResultsCrud from '$lib/services/task_results'; import * as workBooksCrud from '$features/workbooks/services/workbooks'; import { Roles } from '$lib/types/user'; +import type { TaskGrade, TaskResult } from '$lib/types/task'; import { WorkBookTab, type WorkBookTab as WorkBookTabType, @@ -59,16 +60,16 @@ export async function load({ locals, url }) { const selectedGrade = parseWorkBookGrade(params); const selectedCategory = parseWorkBookCategory(params); - // Phase 2: All tab content is gated by {#if loggedInUser}, so anonymous users - // render nothing. Skip the heavy workbook/category fetches and return a + // All tab content is gated by {#if loggedInUser}, so anonymous users render + // nothing. Skip the heavy workbook/category fetches and return a // display-equivalent empty shape (keys preserved for the page's $effect/$derived). if (!loggedInUser) { return { workbooks: [], availableCategories: [], solutionCategoryMap: new Map(), - tasksMapByIds: new Map(), - taskResultsByTaskId: new Map(), + tasksMapByIds: new Map(), + taskResultsByTaskId: new Map(), loggedInUser: null, tab, selectedGrade, diff --git a/src/routes/workbooks/+page.svelte b/src/routes/workbooks/+page.svelte index 59e6406b7..8120d574d 100644 --- a/src/routes/workbooks/+page.svelte +++ b/src/routes/workbooks/+page.svelte @@ -101,6 +101,9 @@ contentClass="bg-white dark:bg-gray-800 mt-0 p-0" ulClass="flex flex-wrap md:flex-nowrap md:gap-2 rtl:space-x-reverse items-start" > + + + {#if loggedInUser} Date: Sun, 14 Jun 2026 08:53:18 +0000 Subject: [PATCH 3/3] docs: remove Phase 2 sub-plan after merging todos into parent plan Co-Authored-By: Claude Sonnet 4.6 --- .../workbooks-anonymous-early-return/plan.md | 106 ------------------ 1 file changed, 106 deletions(-) delete mode 100644 docs/dev-notes/2026-06-14/workbooks-anonymous-early-return/plan.md diff --git a/docs/dev-notes/2026-06-14/workbooks-anonymous-early-return/plan.md b/docs/dev-notes/2026-06-14/workbooks-anonymous-early-return/plan.md deleted file mode 100644 index 6a394401b..000000000 --- a/docs/dev-notes/2026-06-14/workbooks-anonymous-early-return/plan.md +++ /dev/null @@ -1,106 +0,0 @@ -# Phase 2:workbooks load の匿名早期 return - -> 親プラン:[docs/dev-notes/2026-06-13/sveltekit-caching/plan.md](../../2026-06-13/sveltekit-caching/plan.md) の Phase 2。 - -## Context(なぜやるか) - -Vercel の Function Duration / Fast Origin Transfer が直近で約1.5倍に増加。`/workbooks` の -load は**匿名アクセスでも重い問題集フェッチを毎回実行**している。一方 -[+page.svelte:104-160](../../../../src/routes/workbooks/+page.svelte#L104-L160) のタブ中身は丸ごと -`{#if loggedInUser}` で囲まれており、**匿名ユーザーには何も表示されない**。 - -つまり匿名(bot / クローラー含む)に対し「取得してゼロ表示」のムダが発生している。 -Phase 1(参照タスクのみ取得)完了後、匿名で残る重いフェッチは Promise.all 内の以下のみ: - -- `fetchWorkbooksByTab` → `getWorkbooksByPlacement` / `getWorkBooksCreatedByUsers`(本番 ≈0.28–0.55 MB) -- `getAvailableSolutionCategories`(SOLUTION タブ) -- `getSolutionCategoryMapByWorkbookId`(SOLUTION ALL) - -本 Phase はこれらを匿名時に丸ごとスキップする。**表示は不変**(匿名は従来どおり空ページ)。 - -## 方針(確定済み) - -- **CDN キャッシュ(s-maxage)は付けない** — fetch スキップのみに限定。匿名 CDN キャッシュは - problems 向け Phase 3 と同型で別途扱う。invocation は安価($0.60)で早期 return により - Duration は既に最小化されるため、本 Phase での追加価値は小さい。 -- **匿名の挙動は現状維持** — 空ページを返す(`/login` リダイレクトはしない)。SEO 発見性と - 親プランの「行儀の良い bot を安く受け入れる」方針を維持するため。 -- **テストは別チケット** — route load テストは repo に前例がなく、Phase 1 の残 TODO - (`fetches only tasks referenced by displayed workbooks` の load 統合テスト)と同じ mock 基盤を - 要する。両者をまとめて別タスクで起票し、本 Phase は実装+手動検証で完了とする。 - -## 変更内容 - -### 対象ファイル - -- [src/routes/workbooks/+page.server.ts](../../../../src/routes/workbooks/+page.server.ts)(唯一の変更ファイル) - -### 1. 匿名早期 return の追加 - -`selectedGrade` / `selectedCategory` をパースした直後(現 +page.server.ts:60 の後、`adminUser` 計算と try ブロックの前)に挿入する。 - -- `CREATED_BY_USER` リダイレクト(+page.server.ts:52-57)は**早期 return より前**のまま維持 - (匿名が `CREATED_BY_USER` タブに来たら従来どおり `/workbooks` へリダイレクト)。 -- 返却 shape は**ログイン時と同じキーをすべて保持**し、値だけ空にする。 - `+page.svelte` の `$effect`(sessionStorage、`data.tab`/`selectedGrade`/`selectedCategory` を参照)と - `$derived`(`data.workbooks` / 各 Map を参照)が壊れないため。 - -```typescript -// Phase 2: All tab content is gated by {#if loggedInUser}, so anonymous users -// render nothing. Skip the heavy workbook/category fetches and return a -// display-equivalent empty shape (keys preserved for the page's $effect/$derived). -if (!loggedInUser) { - return { - workbooks: [], - availableCategories: [], - solutionCategoryMap: new Map(), - tasksMapByIds: new Map(), - taskResultsByTaskId: new Map(), - loggedInUser: null, - tab, - selectedGrade, - selectedCategory, - }; -} -``` - -### 2. 早期 return により冗長化したガードの除去(複雑度の削減) - -早期 return 以降、`loggedInUser` は非 null が保証される。以下の null ガードは -**到達不能(dead)** になるため、可読性向上のため除去する(「ここで null になり得るか?」の曖昧さを消す): - -- +page.server.ts:73-75 - `loggedInUser ? getTaskResultsOnlyResultExists(loggedInUser.id, true) : Promise.resolve(new Map())` - → `getTaskResultsOnlyResultExists(loggedInUser.id, true)` -- +page.server.ts:82 - `tab === WorkBookTab.CURRICULUM && loggedInUser ? buildTaskIdsFromWorkbooks(workbooks) : []` - → `tab === WorkBookTab.CURRICULUM ? buildTaskIdsFromWorkbooks(workbooks) : []` -- `adminUser`(+page.server.ts:61)の `loggedInUser && isAdmin(...)` も `isAdmin(...)` に簡約可 - (`!!adminUser` は `adminUser` のままで可)。併せて +page.server.ts:78-80 付近の - 「非 CURRICULUM・匿名で空 Map」を説明するコメントも、匿名分岐が消えたため文面を更新する。 - -> 注:この除去は本 Phase の早期 return が直接生むクリーンアップであり、スコープ内。 -> Phase 1 のロジック自体(参照タスク取得)は変えない。 - -## 検証(手動) - -テスト自動化は別チケットのため、本 Phase はローカル手動検証で完了確認する。 - -1. `pnpm dev` で起動。 -2. **匿名**(セッション cookie なし / シークレットウィンドウ)で `/workbooks` にアクセス: - - 見出し `問題集` のみ表示・空タブ(エラーなし=従来どおり)。 - - DevTools の SSR レスポンス(document)ペイロードが大幅に縮小していること(問題集データが載っていない)。 - - 一時ログ or ブレークポイントで `getWorkbooksByPlacement` / `getWorkBooksCreatedByUsers` / - `getAvailableSolutionCategories` / `getSolutionCategoryMapByWorkbookId` が**呼ばれない**ことを確認。 -3. **ログイン**で `/workbooks` の各タブ(CURRICULUM / SOLUTION / ユーザ作成)にアクセス: - - 従来どおり問題集一覧・grade modes・解答状況が表示される(回帰なし)。 - - `?category=` 付き SOLUTION ALL でも `solutionCategoryMap` が機能する。 -4. **匿名 + `?tab=created_by_user`**:`/workbooks` へリダイレクトされる(既存挙動の維持)。 -5. `pnpm check`(型)と `pnpm lint` が通ること。`pnpm test:unit` が既存分グリーンであること。 - -## 残タスク(後続 TODO へ引き継ぎ) - -- [ ] **route load 統合テスト基盤**を別チケットで起票し、Phase 1 残 TODO と統合: - - `anonymous request skips heavy fetches`(service mock で匿名時の未呼び出しをアサート) - - `fetches only tasks referenced by displayed workbooks`(Phase 1 残) -- [ ] 本番相当データ(問題集150件規模)で payload 再計測 → Phase 5 要否判断の材料に。