@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/dist/client.d.mts +14 -6
- package/dist/client.d.ts +14 -6
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -5
- package/dist/client.mjs.map +1 -1
- package/package.json +4 -4
- package/src/client/components/daily-report-detail-list.tsx +69 -47
- package/src/client/components/daily-report-resolved-content.tsx +1 -1
- package/src/client/components/daily-report-scroll-restoration.spec.tsx +98 -59
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiquants/daily-report",
|
|
3
|
-
"version": "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.
|
|
73
|
+
"vitest": "^4.1.10",
|
|
74
74
|
"zod": "^3.25.76",
|
|
75
75
|
"@aiquants/swipe-overlay": "1.2.4",
|
|
76
|
-
"@aiquants/virtualscroll": "1.
|
|
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.
|
|
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
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// 誤分類され、初回の選択スクロールが復元位置 (先頭)
|
|
119
|
-
|
|
119
|
+
// 復元アンカーと「復元マウントかどうか」はマウント時に一度だけ共有スロットから決める。
|
|
120
|
+
// 復元判定は選択アンカー (記録時の選択日報) の一致のみで行う。❗ 位置の有無や大小を復元
|
|
121
|
+
// 判定に流用してはならない: 先頭まで戻してからタブを離れた復元マウントが「非復元」に
|
|
122
|
+
// 誤分類され、初回の選択スクロールが復元位置 (先頭) を上書きしてしまう。
|
|
123
|
+
// topItem は reportHubId の同一性で現在の一覧へ再解決する (留守中の並び替え・挿入に不変)。
|
|
124
|
+
// 消えていたら保存時 index への近似フォールバック (行内オフセットは破棄 = カード上端合わせ)
|
|
125
|
+
const [{ initialScrollAnchor, restored: isRestoredMount }] = useState(() => {
|
|
120
126
|
const restore = scrollRestoreRef?.current
|
|
121
|
-
if (restore
|
|
122
|
-
return {
|
|
127
|
+
if (!restore || restore.anchorReportHubId !== (selectedItemId ?? null)) {
|
|
128
|
+
return { initialScrollAnchor: undefined, restored: false }
|
|
123
129
|
}
|
|
124
|
-
|
|
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
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
278
|
-
|
|
301
|
+
const container = scrollContainerRef.current
|
|
302
|
+
if (!container) return
|
|
279
303
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
304
|
+
if (container.dataset.scrolling !== "true") {
|
|
305
|
+
container.dataset.scrolling = "true"
|
|
306
|
+
}
|
|
283
307
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
308
|
+
if (scrollEndTimerRef.current) {
|
|
309
|
+
clearTimeout(scrollEndTimerRef.current)
|
|
310
|
+
}
|
|
287
311
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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>({
|
|
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({
|
|
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
|
-
|
|
222
|
-
|
|
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").
|
|
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("
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
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 = {
|
|
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").
|
|
247
|
-
|
|
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("
|
|
251
|
-
|
|
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").
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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={
|
|
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 = {
|
|
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={
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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={
|
|
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: {
|
|
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)
|
|
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("
|
|
345
|
-
|
|
346
|
-
|
|
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({
|
|
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
|
|