@aiquants/daily-report 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiquants/daily-report",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Reusable daily-report feature package: shared types/schemas, React (Router v7) UI with virtual scrolling + optimistic updates + SSE sync, and a drizzle (mssql) server layer with DI ports (auth, user resolution, redis, external sources).",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -70,10 +70,10 @@
70
70
  "tailwindcss-animate": "^1.0.7",
71
71
  "tsup": "^8.5.1",
72
72
  "typescript": "^5.9.3",
73
- "vitest": "^4.1.8",
73
+ "vitest": "^4.1.10",
74
74
  "zod": "^3.25.76",
75
75
  "@aiquants/swipe-overlay": "1.2.4",
76
- "@aiquants/virtualscroll": "1.24.0"
76
+ "@aiquants/virtualscroll": "1.25.0"
77
77
  },
78
78
  "peerDependencies": {
79
79
  "drizzle-orm": ">=1.0.0-beta.4 <2.0.0",
@@ -82,7 +82,7 @@
82
82
  "react-router": "^7.0.0",
83
83
  "zod": ">=3.25.0 <5.0.0",
84
84
  "@aiquants/swipe-overlay": "^1.2.4",
85
- "@aiquants/virtualscroll": "^1.24.0"
85
+ "@aiquants/virtualscroll": "^1.25.0"
86
86
  },
87
87
  "peerDependenciesMeta": {
88
88
  "drizzle-orm": {
@@ -83,14 +83,18 @@ const formatInterviewerLabel = (interviewer: DailyReportDetail["interviewers"][n
83
83
  * Scroll restore slot for DailyReportDetailList that survives tab unmounting.
84
84
  * タブアンマウントをまたいで DailyReportDetailList のスクロール位置を持ち回る共有スロット。
85
85
  *
86
- * offset は VirtualScroll の論理座標。anchorReportHubId は「その offset を記録した時点の
87
- * 選択日報」。復元はアンカーが現在の選択と一致するときだけ行う: 選択が他ビューで変わって
88
- * いたら、復元よりも「選択日報へのスクロール」(既存 UX) を優先する。
89
- * measuredHeights は実測カード高さ (reportHubId → px)。ピクセル復元は「保存時と同じ高さ
90
- * 空間」でだけ正しいため、実測をアンマウント越しに持ち回って座標空間を保存する。
86
+ * topItem は先頭可視カードの**同一性アンカー** (reportHubId + 行内オフセット px + 保存時 index)。
87
+ * ❗ 生の論理 px を保存してはならない: 可変高さでは px→行変換が再マウントの推定空間で行われ、
88
+ * 深い位置で別のカードに着地する (実機で確定した実害バグ)。同一性アンカーは一覧が留守中に
89
+ * 変わっても findIndex で正しいカードへ再解決される。index は該当カードが消えていたときの
90
+ * 近似フォールバック。offsetPx はカード上端がビューポート上端より上に隠れている px (論理座標)。
91
+ * anchorReportHubId は「記録時点の選択日報」。復元はこれが現在の選択と一致するときだけ行う:
92
+ * 選択が他ビューで変わっていたら、復元よりも「選択日報へのスクロール」(既存 UX) を優先する。
93
+ * measuredHeights は実測カード高さ (reportHubId → px)。着地窓の行内オフセットの厳密さと
94
+ * 総高さ推定の質を保つため、実測をアンマウント越しに持ち回る。
91
95
  */
92
96
  export type DailyReportDetailListScrollRestore = {
93
- offset: number
97
+ topItem: { reportHubId: number; index: number; offsetPx: number } | null
94
98
  anchorReportHubId: number | null
95
99
  measuredHeights: Map<number, number>
96
100
  }
@@ -112,16 +116,25 @@ type DailyReportDetailListProps = {
112
116
  * 動的高さの仮想スクロールで日報詳細カードを描画するコンポーネント。
113
117
  */
114
118
  export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId, onSelectItem, scrollRestoreRef }: DailyReportDetailListProps) => {
115
- // 復元位置と「復元マウントかどうか」はマウント時に一度だけ共有スロットから決める。
116
- // 復元判定はアンカー (記録時の選択日報) の一致のみで行う。❗ `offset > 0` を復元判定に
117
- // 流用してはならない: 先頭 (論理 0) まで戻してからタブを離れた復元マウントが「非復元」に
118
- // 誤分類され、初回の選択スクロールが復元位置 (先頭) を上書きしてしまう
119
- const [{ offset: initialScrollOffset, restored: isRestoredMount }] = useState(() => {
119
+ // 復元アンカーと「復元マウントかどうか」はマウント時に一度だけ共有スロットから決める。
120
+ // 復元判定は選択アンカー (記録時の選択日報) の一致のみで行う。❗ 位置の有無や大小を復元
121
+ // 判定に流用してはならない: 先頭まで戻してからタブを離れた復元マウントが「非復元」に
122
+ // 誤分類され、初回の選択スクロールが復元位置 (先頭) を上書きしてしまう。
123
+ // topItem は reportHubId の同一性で現在の一覧へ再解決する (留守中の並び替え・挿入に不変)。
124
+ // 消えていたら保存時 index への近似フォールバック (行内オフセットは破棄 = カード上端合わせ)
125
+ const [{ initialScrollAnchor, restored: isRestoredMount }] = useState(() => {
120
126
  const restore = scrollRestoreRef?.current
121
- if (restore && restore.anchorReportHubId === (selectedItemId ?? null)) {
122
- return { offset: restore.offset, restored: true }
127
+ if (!restore || restore.anchorReportHubId !== (selectedItemId ?? null)) {
128
+ return { initialScrollAnchor: undefined, restored: false }
123
129
  }
124
- return { offset: 0, restored: false }
130
+ if (!restore.topItem) {
131
+ return { initialScrollAnchor: undefined, restored: true }
132
+ }
133
+ const foundIndex = dailyReportItems.findIndex((item) => item.reportHubId === restore.topItem?.reportHubId)
134
+ if (foundIndex !== -1) {
135
+ return { initialScrollAnchor: { index: foundIndex, offsetPx: restore.topItem.offsetPx }, restored: true }
136
+ }
137
+ return { initialScrollAnchor: { index: restore.topItem.index, offsetPx: 0 }, restored: true }
125
138
  })
126
139
  // 復元マウントの初回だけ「選択日報へのスクロール」をスキップするための武装フラグと、
127
140
  // 消費条件 (マウント時と同じ選択であること) の基準値。
@@ -133,13 +146,10 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
133
146
  // 実測高さは ref にインプレースで持つ。以前は測定行ごとに `new Map(prev)` を作っており
134
147
  // 行数に対して O(n^2) だった。VirtualScroll へは updateItemSize で個別に伝えるため、
135
148
  // ここで state を持って再レンダーを起こす必要はない (getItemHeight は ref を直接読む)。
136
- // ❗ 共有スロットがあればその Map を使う (アンマウントで実測空間を捨てない): ピクセル
137
- // 復元は「保存時と同じ高さ空間」に近いほど正しい。ローカル Map で作り直すと全カードが
138
- // 既定高さ推定に戻り、同じ offset が別のカード位置に着地する。
139
- // 既知の限界: VirtualScroll の Fenwick 具現化はマウント時サンプル窓 (先頭約 51 行) に
140
- // 限られるため、それより深く実測してからの復元は具現化されない行が推定値で寄与し、
141
- // カード単位の漂着が残り得る (一覧 100 件超 + 51 枚超を連続実測した深い復元のみ。
142
- // 恒久解は index アンカー復元 API — virtualscroll 側のフォローアップ設計を参照)
149
+ // ❗ 共有スロットがあればその Map を使う (アンマウントで実測空間を捨てない): 同一性アンカー
150
+ // 復元の下で、実測の持ち回りは (a) アンカーカードの行内オフセットの厳密さ (そのカードの
151
+ // 実測高さが行内 px の座標系そのもの) と (b) 総高さ推定・周辺カードの初期配置の質を保つ。
152
+ // カードの同一性は topItem (reportHubId) が保証するため、深い復元でも別カードへは漂着しない
143
153
  const itemHeightsRef = useRef<Map<number, number>>(scrollRestoreRef?.current.measuredHeights ?? new Map())
144
154
  const scrollContainerRef = useRef<HTMLDivElement | null>(null)
145
155
  const virtualScrollRef = useRef<VirtualScrollHandle>(null)
@@ -264,36 +274,48 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
264
274
  [getItem, itemCount, schedulePrefetchForRange],
265
275
  )
266
276
 
267
- const handleScroll = useCallback(
268
- (position: number) => {
269
- // onScroll が配信する論理座標をそのまま共有スロットへ書く (保存と復元で座標系を一致させる)。
270
- // ❗ getScrollPosition() の読み戻しへ戻さないこと: あれはペイン座標 (inset 込み) で、
271
- // 論理座標を受け取る initialScrollOffset との往復ごとに inset 分ずれる
272
- const restore = scrollRestoreRef?.current
273
- if (restore) {
274
- restore.offset = position
277
+ // ❗ teardown 用ハンドルミラー: React は削除コミットの mutation phase で virtualScrollRef を
278
+ // null 化するため、アンマウント時の RAF flush (最終 onScroll 配信) の時点では素の ref が
279
+ // 読めない。最後に観測した非 null ハンドルを保持し、フリング中のタブ切替でも最終アンカーを
280
+ // 取りこぼさない (closure 経由の読取は detach 後も直近コミットの値で安全)
281
+ const lastVirtualScrollHandleRef = useRef<VirtualScrollHandle | null>(null)
282
+
283
+ const handleScroll = useCallback(() => {
284
+ // スクロールのたびに先頭可視カードの同一性アンカーを共有スロットへ書き戻す。
285
+ // ❗ 生の論理 px (onScroll 引数や getScrollPosition) を保存してはならない:
286
+ // 可変高さでは再マウントの推定空間で px→行変換が狂い、別のカードに着地する。
287
+ // getScrollAnchor は可視ウィンドウの実測が照合済みのツリーで計算されるため常に正確
288
+ if (virtualScrollRef.current) {
289
+ lastVirtualScrollHandleRef.current = virtualScrollRef.current
290
+ }
291
+ const restore = scrollRestoreRef?.current
292
+ const handle = virtualScrollRef.current ?? lastVirtualScrollHandleRef.current
293
+ if (restore && handle) {
294
+ const anchor = handle.getScrollAnchor()
295
+ const item = anchor ? dailyReportItemsRef.current[anchor.index] : undefined
296
+ if (anchor && item) {
297
+ restore.topItem = { reportHubId: item.reportHubId, index: anchor.index, offsetPx: anchor.offsetPx }
275
298
  }
299
+ }
276
300
 
277
- const container = scrollContainerRef.current
278
- if (!container) return
301
+ const container = scrollContainerRef.current
302
+ if (!container) return
279
303
 
280
- if (container.dataset.scrolling !== "true") {
281
- container.dataset.scrolling = "true"
282
- }
304
+ if (container.dataset.scrolling !== "true") {
305
+ container.dataset.scrolling = "true"
306
+ }
283
307
 
284
- if (scrollEndTimerRef.current) {
285
- clearTimeout(scrollEndTimerRef.current)
286
- }
308
+ if (scrollEndTimerRef.current) {
309
+ clearTimeout(scrollEndTimerRef.current)
310
+ }
287
311
 
288
- scrollEndTimerRef.current = setTimeout(() => {
289
- if (container) {
290
- delete container.dataset.scrolling
291
- }
292
- scrollEndTimerRef.current = null
293
- }, 150)
294
- },
295
- [scrollRestoreRef],
296
- )
312
+ scrollEndTimerRef.current = setTimeout(() => {
313
+ if (container) {
314
+ delete container.dataset.scrolling
315
+ }
316
+ scrollEndTimerRef.current = null
317
+ }, 150)
318
+ }, [scrollRestoreRef])
297
319
 
298
320
  const renderThumbOverlay = useCallback((props: ScrollBarThumbOverlayRenderProps) => <BusinessDayThumbOverlay {...props} label={overlayLabel} isWheelScrollActive={isWheelScrollActive} />, [overlayLabel, isWheelScrollActive])
299
321
 
@@ -387,7 +409,7 @@ export const DailyReportDetailList = ({ dailyReportItems, userId, selectedItemId
387
409
  getItemKey={getItemKey}
388
410
  getItemHeight={getItemHeight}
389
411
  viewportSize={viewportHeight}
390
- initialScrollOffset={initialScrollOffset}
412
+ initialScrollAnchor={initialScrollAnchor}
391
413
  overscanCount={VIRTUAL_SCROLL_OVERSCAN_COUNT}
392
414
  scrollBarOptions={{
393
415
  width: SCROLL_BAR_WIDTH,
@@ -42,7 +42,7 @@ export const DailyReportResolvedContent = ({ userId }: DailyReportResolvedConten
42
42
  // タブは非制御でこのコンポーネントはタブ切替時に再レンダーされないため、レンダー時読取は
43
43
  // 古い値の焼き込みになる (タブ復帰でスクロール位置がリセットされた実害バグの根本原因)
44
44
  const listScrollOffsetRef = useRef<number>(0)
45
- const detailListScrollRestoreRef = useRef<DailyReportDetailListScrollRestore>({ offset: 0, anchorReportHubId: null, measuredHeights: new Map() })
45
+ const detailListScrollRestoreRef = useRef<DailyReportDetailListScrollRestore>({ topItem: null, anchorReportHubId: null, measuredHeights: new Map() })
46
46
 
47
47
  // 選択状態の管理 (タブ間で共有)
48
48
  const [selectedItemId, setSelectedItemId] = useState<number | null>(() => {
@@ -25,6 +25,7 @@ import type { DailyReportDetail, DailyReportItem } from "../../shared/types"
25
25
  type CapturedProps = {
26
26
  testId?: string
27
27
  initialScrollOffset?: number
28
+ initialScrollAnchor?: { index: number; offsetPx?: number }
28
29
  onScroll?: (position: number, totalHeight: number) => void
29
30
  getItemHeight?: (index: number) => number
30
31
  }
@@ -32,6 +33,8 @@ type CapturedProps = {
32
33
  const captured: CapturedProps[] = []
33
34
  /** 代役の命令ハンドル経由で観測した scrollToIndex 呼び出し。 */
34
35
  const scrollToIndexCalls: Array<{ testId?: string; index: number }> = []
36
+ /** 代役の getScrollAnchor が返す値 (テストごとに差し替える)。 */
37
+ let scriptedAnchor: { index: number; offsetPx: number } | null = { index: 0, offsetPx: 0 }
35
38
 
36
39
  /**
37
40
  * Stand-in for VirtualScroll that renders every item and records scroll wiring.
@@ -51,12 +54,20 @@ vi.mock("@aiquants/virtualscroll", async () => {
51
54
  } & CapturedProps,
52
55
  ref: React.Ref<unknown>,
53
56
  ) => {
54
- captured.push({ testId: props.testId, initialScrollOffset: props.initialScrollOffset, onScroll: props.onScroll, getItemHeight: (props as { getItemHeight?: (index: number) => number }).getItemHeight })
57
+ captured.push({
58
+ testId: props.testId,
59
+ initialScrollOffset: props.initialScrollOffset,
60
+ initialScrollAnchor: (props as CapturedProps).initialScrollAnchor,
61
+ onScroll: props.onScroll,
62
+ getItemHeight: (props as { getItemHeight?: (index: number) => number }).getItemHeight,
63
+ })
55
64
  useHandle(ref, () => ({
56
65
  scrollTo: () => 0,
57
66
  scrollToIndex: (index: number) => {
58
67
  scrollToIndexCalls.push({ testId: props.testId, index })
59
68
  },
69
+ // テストごとに scriptedAnchor で差し替える (handleScroll の保存経路の検証用)
70
+ getScrollAnchor: () => scriptedAnchor,
60
71
  getScrollPosition: () => 0,
61
72
  getContentSize: () => 0,
62
73
  getViewportSize: () => 0,
@@ -171,6 +182,7 @@ const allOf = (testId: string) => captured.filter((entry) => entry.testId === te
171
182
  beforeEach(() => {
172
183
  captured.length = 0
173
184
  scrollToIndexCalls.length = 0
185
+ scriptedAnchor = { index: 0, offsetPx: 0 }
174
186
  contextItemsRef.current = []
175
187
  vi.stubGlobal(
176
188
  "ResizeObserver",
@@ -217,56 +229,56 @@ describe("DailyReportList のスクロール共有スロット", () => {
217
229
  })
218
230
  })
219
231
 
220
- describe("DailyReportDetailList のアンカー付き復元", () => {
221
- it("アンカーが現在の選択と一致すれば復元し、初回の選択スクロールはスキップする", () => {
222
- const restoreRef = { current: { offset: 900, anchorReportHubId: 42, measuredHeights: new Map<number, number>() } }
232
+ describe("DailyReportDetailList の同一性アンカー復元", () => {
233
+ const cfg = { apiBasePath: "/daily_report/api", fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" } }
234
+ const slot = (over: Partial<{ topItem: { reportHubId: number; index: number; offsetPx: number } | null; anchorReportHubId: number | null }> = {}) => ({
235
+ current: { topItem: null as { reportHubId: number; index: number; offsetPx: number } | null, anchorReportHubId: null as number | null, measuredHeights: new Map<number, number>(), ...over },
236
+ })
237
+
238
+ it("選択アンカー一致なら topItem を同一性で再解決して復元し、初回の選択スクロールはスキップする", () => {
239
+ const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 137 }, anchorReportHubId: 42 })
223
240
  renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
224
- expect(latestOf("daily-report-detail-list").initialScrollOffset).toBe(900)
225
- // 復元マウントでは scrollToIndex(選択) が走らない (復元位置の上書き防止)
241
+ expect(latestOf("daily-report-detail-list").initialScrollAnchor).toEqual({ index: 0, offsetPx: 137 })
226
242
  expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
227
243
  })
228
244
 
229
- it("復元マウント後の選択変更では従来どおり選択日報へスクロールする (スキップは 1 回だけ)", () => {
230
- const restoreRef = { current: { offset: 900, anchorReportHubId: 42, measuredHeights: new Map<number, number>() } }
231
- const items = [buildItem(41), buildItem(42)]
232
- const view = renderView(<DailyReportDetailList dailyReportItems={items} selectedItemId={42} scrollRestoreRef={restoreRef} />)
233
- view.rerender(
234
- <DailyReportConfigProvider config={{ apiBasePath: "/daily_report/api", fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" } }}>
235
- <DailyReportDetailList dailyReportItems={items} selectedItemId={41} scrollRestoreRef={restoreRef} />
236
- </DailyReportConfigProvider>,
237
- )
238
- expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([{ testId: "daily-report-detail-list", index: 0 }])
239
- // アンカー同期の副作用が選択変更を追従する
240
- expect(restoreRef.current.anchorReportHubId).toBe(41)
245
+ it("留守中に一覧が並び替わっても、topItem は reportHubId の同一性で新しい index へ再解決される", () => {
246
+ // 保存時: カード 41 が index 0。復帰時: 先頭に 43 が挿入され 41 は index 1 へ移動
247
+ const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 137 }, anchorReportHubId: 42 })
248
+ renderView(<DailyReportDetailList dailyReportItems={[buildItem(43), buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
249
+ expect(latestOf("daily-report-detail-list").initialScrollAnchor).toEqual({ index: 1, offsetPx: 137 })
241
250
  })
242
251
 
243
- it("アンカーが選択と不一致なら復元せず、従来どおり選択日報へスクロールする", () => {
244
- const restoreRef = { current: { offset: 900, anchorReportHubId: 41, measuredHeights: new Map<number, number>() } }
252
+ it("topItem のカードが留守中に消えていたら、保存時 index への近似フォールバック (行内オフセットは破棄)", () => {
253
+ const restoreRef = slot({ topItem: { reportHubId: 99, index: 1, offsetPx: 137 }, anchorReportHubId: 42 })
245
254
  renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
246
- expect(latestOf("daily-report-detail-list").initialScrollOffset).toBe(0)
247
- expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([{ testId: "daily-report-detail-list", index: 1 }])
255
+ expect(latestOf("daily-report-detail-list").initialScrollAnchor).toEqual({ index: 1, offsetPx: 0 })
256
+ // 復元マウント扱いなので初回の選択スクロールはスキップされる
257
+ expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
248
258
  })
249
259
 
250
- it("アンカー一致かつ offset 0 の復元マウントでも初回の選択スクロールをスキップする (先頭復元の上書き防止)", () => {
251
- // 復元判定は「アンカー一致」であって「offset > 0」ではない: 先頭まで戻してから
252
- // タブを離れた場合も復元マウントであり、選択日報へ跳んではならない
253
- const restoreRef = { current: { offset: 0, anchorReportHubId: 42, measuredHeights: new Map<number, number>() } }
260
+ it("先頭 (topItem が行 0 の offsetPx 0) の復元でも復元マウント扱いで、選択スクロールが先頭を上書きしない", () => {
261
+ const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 0 }, anchorReportHubId: 42 })
254
262
  renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
255
- expect(latestOf("daily-report-detail-list").initialScrollOffset).toBe(0)
263
+ expect(latestOf("daily-report-detail-list").initialScrollAnchor).toEqual({ index: 0, offsetPx: 0 })
256
264
  expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
257
265
  })
258
266
 
259
- it("アンカー日報がアンマウント中に消えていても、次の正当な選択変更のスクロールを飲み込まない", () => {
260
- // 武装したままの skip フラグが「解決できないアンカー」で残留し、その後の選択変更
261
- // (新規作成ボタン等) を 1 回無言で飲み込む退行の検知。武装解除して通常スクロールする
262
- const restoreRef = { current: { offset: 900, anchorReportHubId: 42, measuredHeights: new Map<number, number>() } }
263
- const items = [buildItem(41), buildItem(43)] // 42 は削除済み
267
+ it("選択アンカー不一致なら復元せず、従来どおり選択日報へスクロールする", () => {
268
+ const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: 41 })
269
+ renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
270
+ expect(latestOf("daily-report-detail-list").initialScrollAnchor).toBeUndefined()
271
+ expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([{ testId: "daily-report-detail-list", index: 1 }])
272
+ })
273
+
274
+ it("消えたアンカー選択の復元でも、次の正当な選択変更のスクロールを飲み込まない", () => {
275
+ const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: 42 })
276
+ const items = [buildItem(41), buildItem(43)] // 選択 42 は削除済み
264
277
  const view = renderView(<DailyReportDetailList dailyReportItems={items} selectedItemId={42} scrollRestoreRef={restoreRef} />)
265
- expect(latestOf("daily-report-detail-list").initialScrollOffset).toBe(900)
266
278
  expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
267
279
 
268
280
  view.rerender(
269
- <DailyReportConfigProvider config={{ apiBasePath: "/daily_report/api", fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" } }}>
281
+ <DailyReportConfigProvider config={cfg}>
270
282
  <DailyReportDetailList dailyReportItems={items} selectedItemId={43} scrollRestoreRef={restoreRef} />
271
283
  </DailyReportConfigProvider>,
272
284
  )
@@ -274,11 +286,11 @@ describe("DailyReportDetailList のアンカー付き復元", () => {
274
286
  })
275
287
 
276
288
  it("null アンカーの復元マウントでも、最初の選択確定のスクロールを飲み込まない", () => {
277
- const restoreRef = { current: { offset: 900, anchorReportHubId: null, measuredHeights: new Map<number, number>() } }
289
+ const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: null })
278
290
  const items = [buildItem(41), buildItem(42)]
279
291
  const view = renderView(<DailyReportDetailList dailyReportItems={items} selectedItemId={null} scrollRestoreRef={restoreRef} />)
280
292
  view.rerender(
281
- <DailyReportConfigProvider config={{ apiBasePath: "/daily_report/api", fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" } }}>
293
+ <DailyReportConfigProvider config={cfg}>
282
294
  <DailyReportDetailList dailyReportItems={items} selectedItemId={41} scrollRestoreRef={restoreRef} />
283
295
  </DailyReportConfigProvider>,
284
296
  )
@@ -286,15 +298,12 @@ describe("DailyReportDetailList のアンカー付き復元", () => {
286
298
  })
287
299
 
288
300
  it("復元スキップの消費は「スクロール済み」として記録され、直後の items 参照更新で復元が壊れない", () => {
289
- // skip 消費時に lastScrolledRef を記録しないと、次の items 配列更新 (SSE チャンク等) で
290
- // 効果が再実行され、消費済みフラグの保護が無いまま選択スクロールが復元位置を上書きする
291
- const restoreRef = { current: { offset: 900, anchorReportHubId: 42, measuredHeights: new Map<number, number>() } }
301
+ const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: 42 })
292
302
  const view = renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
293
303
  expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([])
294
304
 
295
- // 同一内容・新しい配列 identity で再レンダー (ストリーム更新の最小モデル)
296
305
  view.rerender(
297
- <DailyReportConfigProvider config={{ apiBasePath: "/daily_report/api", fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" } }}>
306
+ <DailyReportConfigProvider config={cfg}>
298
307
  <DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />
299
308
  </DailyReportConfigProvider>,
300
309
  )
@@ -302,25 +311,19 @@ describe("DailyReportDetailList のアンカー付き復元", () => {
302
311
  })
303
312
 
304
313
  it("不一致経路の武装解除は恒久: 後からマウント時と同じ選択へ戻っても飲み込まない", () => {
305
- // 武装解除を「一致分岐の中」へ動かす退行 (不一致では解除されない) の検知。
306
- // 不一致で解除されないと、後でマウント時選択と同じ値へ選択が戻ったときに
307
- // 残留フラグが消費され、正当なスクロールを 1 回飲み込む
308
- const restoreRef = { current: { offset: 900, anchorReportHubId: 42, measuredHeights: new Map<number, number>() } }
309
- const config = { apiBasePath: "/daily_report/api", fieldLabels: { attachments: "添付ファイル", attachmentMissing: "実体が見つかりません", attachmentDownload: "ダウンロード" } }
314
+ const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: 42 })
310
315
  const view = renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(43)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
311
316
 
312
- // 不一致の選択変更 → 武装解除してスクロール (既存テストと同じ前段)
313
317
  view.rerender(
314
- <DailyReportConfigProvider config={config}>
318
+ <DailyReportConfigProvider config={cfg}>
315
319
  <DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(43)]} selectedItemId={43} scrollRestoreRef={restoreRef} />
316
320
  </DailyReportConfigProvider>,
317
321
  )
318
322
  expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([{ testId: "daily-report-detail-list", index: 1 }])
319
323
 
320
- // アンカー日報が復活し、マウント時と同じ 42 を選択 → 解除済みなので通常どおりスクロールする
321
324
  const revived = [buildItem(41), buildItem(43), buildItem(42)]
322
325
  view.rerender(
323
- <DailyReportConfigProvider config={config}>
326
+ <DailyReportConfigProvider config={cfg}>
324
327
  <DailyReportDetailList dailyReportItems={revived} selectedItemId={42} scrollRestoreRef={restoreRef} />
325
328
  </DailyReportConfigProvider>,
326
329
  )
@@ -331,21 +334,57 @@ describe("DailyReportDetailList のアンカー付き復元", () => {
331
334
  })
332
335
 
333
336
  it("実測カード高さは共有スロットの Map をそのまま使い、アンマウント越しに座標空間を保存する", () => {
334
- // ピクセル復元は「保存時と同じ高さ空間」でだけ正しい。スロットの Map が
335
- // getItemHeight へ直結していること (= 再マウントで既定高さ推定に戻らないこと) を固定する
336
337
  const measuredHeights = new Map<number, number>([[42, 777]])
337
- const restoreRef = { current: { offset: 900, anchorReportHubId: 42, measuredHeights } }
338
+ const restoreRef = { current: { topItem: { reportHubId: 42, index: 1, offsetPx: 900 }, anchorReportHubId: 42, measuredHeights } }
338
339
  renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
339
340
  const getItemHeight = latestOf("daily-report-detail-list").getItemHeight
340
- expect(getItemHeight?.(1)).toBe(777) // index 1 = reportHubId 42 の実測値
341
- expect(getItemHeight?.(0)).not.toBe(777) // 未実測カードは既定推定
341
+ expect(getItemHeight?.(1)).toBe(777)
342
+ expect(getItemHeight?.(0)).not.toBe(777)
342
343
  })
343
344
 
344
- it("onScroll は offset のみ書き、アンカーは選択同期の副作用が管理する", () => {
345
- const restoreRef = { current: { offset: 0, anchorReportHubId: null as number | null, measuredHeights: new Map<number, number>() } }
346
- renderView(<DailyReportDetailList dailyReportItems={[buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
345
+ it("選択変更時にアンカー同期の副作用が anchorReportHubId を新しい選択へ追従させる (null 以外からの変更も)", () => {
346
+ // 復元判定の生命線: この同期が「非 null からの変更」で止まると、古い選択のまま
347
+ // アンカー一致判定が誤爆し、次回復元の可否が壊れる
348
+ const restoreRef = slot({ topItem: { reportHubId: 41, index: 0, offsetPx: 900 }, anchorReportHubId: 42 })
349
+ const items = [buildItem(41), buildItem(42), buildItem(43)]
350
+ const view = renderView(<DailyReportDetailList dailyReportItems={items} selectedItemId={42} scrollRestoreRef={restoreRef} />)
351
+ expect(restoreRef.current.anchorReportHubId).toBe(42)
352
+
353
+ view.rerender(
354
+ <DailyReportConfigProvider config={cfg}>
355
+ <DailyReportDetailList dailyReportItems={items} selectedItemId={43} scrollRestoreRef={restoreRef} />
356
+ </DailyReportConfigProvider>,
357
+ )
358
+ expect(restoreRef.current.anchorReportHubId).toBe(43)
359
+ expect(scrollToIndexCalls.filter((c) => c.testId === "daily-report-detail-list")).toEqual([{ testId: "daily-report-detail-list", index: 2 }])
360
+ })
361
+
362
+ it("アンマウント時の RAF flush (ref null 化後) でも最終アンカーが書き戻される (ハンドルミラー)", () => {
363
+ // React は削除コミットで virtualScrollRef を null 化してから RAF flush の onScroll が
364
+ // 届く。ミラーが無いと最終フリング分のアンカーが失われる
365
+ const restoreRef = slot({ anchorReportHubId: null })
366
+ const view = renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
367
+ // マウント中に一度スクロール (ミラーがハンドルを捕捉)
368
+ scriptedAnchor = { index: 0, offsetPx: 11 }
369
+ const onScroll = latestOf("daily-report-detail-list").onScroll
370
+ act(() => onScroll?.(11, 100000))
371
+ expect(restoreRef.current.topItem).toEqual({ reportHubId: 41, index: 0, offsetPx: 11 })
372
+
373
+ // アンマウント → React が ref を null 化 → その後に flush 相当の onScroll が届く
374
+ view.unmount()
375
+ scriptedAnchor = { index: 1, offsetPx: 77 }
376
+ onScroll?.(777, 100000)
377
+ expect(restoreRef.current.topItem).toEqual({ reportHubId: 42, index: 1, offsetPx: 77 })
378
+ })
379
+
380
+ it("スクロールのたびに getScrollAnchor の結果が topItem (同一性 + 行内オフセット) として書き戻される", () => {
381
+ const restoreRef = slot({ anchorReportHubId: null })
382
+ renderView(<DailyReportDetailList dailyReportItems={[buildItem(41), buildItem(42)]} selectedItemId={42} scrollRestoreRef={restoreRef} />)
383
+ scriptedAnchor = { index: 1, offsetPx: 55 }
347
384
  act(() => latestOf("daily-report-detail-list").onScroll?.(555, 100000))
348
- expect(restoreRef.current).toEqual({ offset: 555, anchorReportHubId: 42, measuredHeights: expect.any(Map) })
385
+ expect(restoreRef.current.topItem).toEqual({ reportHubId: 42, index: 1, offsetPx: 55 })
386
+ // アンカー (選択同期) は副作用が管理している
387
+ expect(restoreRef.current.anchorReportHubId).toBe(42)
349
388
  })
350
389
  })
351
390