@aiquants/daily-report 0.20.0 → 0.21.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/README.md +229 -8
- package/dist/client.d.mts +201 -739
- package/dist/client.d.ts +201 -739
- package/dist/client.js +5 -6
- package/dist/client.mjs +5 -6
- package/dist/index.d.mts +5 -197
- package/dist/index.d.ts +5 -197
- package/dist/index.js +1 -2
- package/dist/index.mjs +1 -2
- package/dist/logger-D3krZrNK.d.mts +0 -14
- package/dist/logger-D3krZrNK.d.ts +0 -14
- package/dist/server.d.mts +114 -744
- package/dist/server.d.ts +114 -744
- package/dist/server.js +1 -2
- package/dist/server.mjs +1 -2
- package/dist/{sse-schema-ChSFm-4q.d.mts → sse-schema-DCxpdETM.d.mts} +1106 -1090
- package/dist/{sse-schema-BdUGThiz.d.ts → sse-schema-DoQw8_om.d.ts} +1106 -1090
- package/dist/types-CqgYQgbH.d.mts +0 -34
- package/dist/types-CqgYQgbH.d.ts +0 -34
- package/package.json +9 -10
- package/dist/client.js.map +0 -1
- package/dist/client.mjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/server.mjs.map +0 -1
- package/src/client/components/business-day-thumb-overlay.tsx +0 -19
- package/src/client/components/daily-report-attachment-indicator.spec.tsx +0 -51
- package/src/client/components/daily-report-attachment-indicator.tsx +0 -45
- package/src/client/components/daily-report-attachment-list.spec.tsx +0 -94
- package/src/client/components/daily-report-attachment-list.tsx +0 -112
- package/src/client/components/daily-report-comment-item.tsx +0 -89
- package/src/client/components/daily-report-comment-section.tsx +0 -166
- package/src/client/components/daily-report-detail-list.tsx +0 -842
- package/src/client/components/daily-report-edit-form.tsx +0 -87
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +0 -93
- package/src/client/components/daily-report-ids-stream-status.tsx +0 -67
- package/src/client/components/daily-report-list.tsx +0 -1232
- package/src/client/components/daily-report-page.spec.tsx +0 -119
- package/src/client/components/daily-report-page.tsx +0 -140
- package/src/client/components/daily-report-resize-panels.spec.tsx +0 -307
- package/src/client/components/daily-report-resolved-content.tsx +0 -207
- package/src/client/components/daily-report-scroll-restoration.spec.tsx +0 -422
- package/src/client/components/report-views.spec.tsx +0 -375
- package/src/client/components/unread-indicator.tsx +0 -13
- package/src/client/config-context.tsx +0 -184
- package/src/client/contexts/daily-report-action-context.spec.tsx +0 -433
- package/src/client/contexts/daily-report-action-context.tsx +0 -1147
- package/src/client/contexts/daily-report-error-context.spec.tsx +0 -66
- package/src/client/contexts/daily-report-error-context.tsx +0 -98
- package/src/client/hooks/use-animated-number.spec.ts +0 -103
- package/src/client/hooks/use-animated-number.ts +0 -69
- package/src/client/hooks/use-daily-report-comments.ts +0 -73
- package/src/client/hooks/use-daily-report-sse-connection.ts +0 -86
- package/src/client/hooks/use-daily-report.spec.ts +0 -546
- package/src/client/hooks/use-daily-report.ts +0 -549
- package/src/client/hooks/use-dynamic-viewport-height.ts +0 -127
- package/src/client/hooks/use-responsive-layout.spec.ts +0 -74
- package/src/client/hooks/use-responsive-layout.ts +0 -63
- package/src/client/route-helpers.spec.ts +0 -100
- package/src/client/route-helpers.ts +0 -61
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +0 -910
- package/src/client/streaming/daily-report-ids-stream-client.ts +0 -730
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +0 -401
- package/src/client/streaming/daily-report-ids-stream-session.ts +0 -319
- package/src/client/ui/button.tsx +0 -42
- package/src/client/ui/cn.ts +0 -14
- package/src/client/ui/input.tsx +0 -21
- package/src/client/ui/label.tsx +0 -16
- package/src/client/ui/switch.tsx +0 -19
- package/src/client/ui/tabs.tsx +0 -40
- package/src/client/ui/textarea.tsx +0 -19
- package/src/client/utils/constants.spec.ts +0 -96
- package/src/client/utils/constants.ts +0 -84
- package/src/client.ts +0 -28
- package/src/index.ts +0 -11
- package/src/lucide-icons.d.ts +0 -6
- package/src/server/authz.spec.ts +0 -57
- package/src/server/authz.ts +0 -99
- package/src/server/cache.spec.ts +0 -556
- package/src/server/cache.ts +0 -303
- package/src/server/etag.ts +0 -18
- package/src/server/external-source.ts +0 -60
- package/src/server/handlers.action.spec.ts +0 -44
- package/src/server/handlers.attachment.spec.ts +0 -470
- package/src/server/handlers.ids-stream.spec.ts +0 -618
- package/src/server/handlers.ts +0 -1638
- package/src/server/handlers.unauthorized-cookie.spec.ts +0 -187
- package/src/server/handlers.visibility.spec.ts +0 -774
- package/src/server/ports.ts +0 -222
- package/src/server/response.ts +0 -37
- package/src/server/schema.spec.ts +0 -30
- package/src/server/schema.ts +0 -344
- package/src/server/service.spec.ts +0 -1130
- package/src/server/service.ts +0 -2045
- package/src/server/sse-reader.spec.ts +0 -302
- package/src/server/sse-reader.ts +0 -267
- package/src/server/test-helpers/handlers-config.ts +0 -321
- package/src/server/transaction-retry.spec.ts +0 -63
- package/src/server/visibility.spec.ts +0 -403
- package/src/server/visibility.ts +0 -373
- package/src/server.spec.ts +0 -160
- package/src/server.ts +0 -131
- package/src/shared/business-date.spec.ts +0 -82
- package/src/shared/business-date.ts +0 -104
- package/src/shared/comment-adapter.spec.ts +0 -38
- package/src/shared/comment-adapter.ts +0 -80
- package/src/shared/ids-stream.spec.ts +0 -154
- package/src/shared/ids-stream.ts +0 -279
- package/src/shared/logger.ts +0 -47
- package/src/shared/sse-schema.spec.ts +0 -50
- package/src/shared/sse-schema.ts +0 -198
- package/src/shared/text-utils.spec.ts +0 -42
- package/src/shared/text-utils.ts +0 -65
- package/src/shared/types.ts +0 -109
- package/src/styles/standalone.entry.css +0 -51
|
@@ -1,549 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Suspense-friendly daily report detail loader (fetch-driven).
|
|
3
|
-
* fetch ベースの日報詳細ローダー。
|
|
4
|
-
*/
|
|
5
|
-
import { useCallback, useEffect, useRef, useState } from "react"
|
|
6
|
-
import { normalizeBusinessDateKey } from "../../shared/business-date"
|
|
7
|
-
import { createLogger, LogLevel } from "../../shared/logger"
|
|
8
|
-
import type { DailyReportDetail } from "../../shared/types"
|
|
9
|
-
import { useDailyReportConfig } from "../config-context"
|
|
10
|
-
import { DAILY_REPORT_FETCH_DEBOUNCE_MS } from "../utils/constants"
|
|
11
|
-
|
|
12
|
-
const logger = createLogger(LogLevel.INFO, "use-daily-report")
|
|
13
|
-
|
|
14
|
-
// --- Configuration & Constants ---
|
|
15
|
-
/**
|
|
16
|
-
* Retrieve global configuration or default.
|
|
17
|
-
* グローバル設定またはデフォルト値を取得する。
|
|
18
|
-
*/
|
|
19
|
-
const getGlobal = (k: string, d: number): number => {
|
|
20
|
-
if (typeof window === "undefined") return d
|
|
21
|
-
const v = (window as unknown as Record<string, unknown>)[k]
|
|
22
|
-
return typeof v === "number" && !Number.isNaN(v) ? v : d
|
|
23
|
-
}
|
|
24
|
-
const TTL = getGlobal("__DAILY_REPORT_CACHE_TTL__", 300_000)
|
|
25
|
-
const REVIVE_MS = getGlobal("__DAILY_REPORT_CACHE_REVIVE_MS__", 60_000)
|
|
26
|
-
|
|
27
|
-
// --- Cache Store (Centralized State) ---
|
|
28
|
-
type CacheEntry<T> = { data: T; expiresAt: number }
|
|
29
|
-
|
|
30
|
-
export const DailyReportCache = {
|
|
31
|
-
reports: new Map<number, CacheEntry<DailyReportDetail>>(),
|
|
32
|
-
lists: new Map<string, CacheEntry<DailyReportDetail[]>>(),
|
|
33
|
-
pendingLists: new Map<string, Promise<DailyReportDetail[]>>(),
|
|
34
|
-
pendingReports: new Map<number, Promise<DailyReportDetail>>(),
|
|
35
|
-
listeners: new Set<(id: number) => void>(),
|
|
36
|
-
locks: new Set<number>(),
|
|
37
|
-
lastMutations: new Map<number, number>(),
|
|
38
|
-
recentDeletes: new Map<number, number>(),
|
|
39
|
-
lastPrune: 0,
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Prune expired entries from the cache.
|
|
43
|
-
* キャッシュから期限切れのエントリを削除する。
|
|
44
|
-
*/
|
|
45
|
-
prune() {
|
|
46
|
-
const now = Date.now()
|
|
47
|
-
// Prune at most once per minute
|
|
48
|
-
if (now - this.lastPrune < 60_000) return
|
|
49
|
-
this.lastPrune = now
|
|
50
|
-
|
|
51
|
-
// SWR のために古いデータを保持する。TTL 超過後すぐには削除せず、十分に古い(例えば 1時間経過した)データのみを破棄する
|
|
52
|
-
const GC_THRESHOLD = 60 * 60 * 1000 // 1 hour
|
|
53
|
-
for (const [id, entry] of this.reports) {
|
|
54
|
-
if (now - entry.expiresAt > GC_THRESHOLD) this.reports.delete(id)
|
|
55
|
-
}
|
|
56
|
-
for (const [key, entry] of this.lists) {
|
|
57
|
-
if (now - entry.expiresAt > GC_THRESHOLD) this.lists.delete(key)
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Clear all cached entries and pending requests.
|
|
63
|
-
* すべてのキャッシュエントリおよび待機中リクエストをクリアする。
|
|
64
|
-
*/
|
|
65
|
-
clear() {
|
|
66
|
-
this.reports.clear()
|
|
67
|
-
this.lists.clear()
|
|
68
|
-
this.pendingLists.clear()
|
|
69
|
-
this.pendingReports.clear()
|
|
70
|
-
this.locks.clear()
|
|
71
|
-
this.lastMutations.clear()
|
|
72
|
-
this.recentDeletes.clear()
|
|
73
|
-
this.listeners.forEach((listener) => {
|
|
74
|
-
listener(-1)
|
|
75
|
-
})
|
|
76
|
-
},
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Check if the cache entry is still valid.
|
|
80
|
-
* キャッシュエントリがまだ有効かを確認する。
|
|
81
|
-
*/
|
|
82
|
-
isValid: (ex: number) => ex > Date.now(),
|
|
83
|
-
/**
|
|
84
|
-
* Notify all listeners of a report update.
|
|
85
|
-
* レポートの更新をすべてのリスナーに通知する。
|
|
86
|
-
*/
|
|
87
|
-
notify: (id: number) => {
|
|
88
|
-
DailyReportCache.listeners.forEach((l) => {
|
|
89
|
-
l(id)
|
|
90
|
-
})
|
|
91
|
-
},
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Retrieve a cached report by ID.
|
|
95
|
-
* ID でキャッシュされたレポートを取得する。
|
|
96
|
-
*/
|
|
97
|
-
getReport(id: number, ignoreExpiry = false) {
|
|
98
|
-
const e = this.reports.get(id)
|
|
99
|
-
return e && (ignoreExpiry || this.isValid(e.expiresAt)) ? e.data : null
|
|
100
|
-
},
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Retrieve a cached list of reports by business date key.
|
|
104
|
-
* 営業日キーでレポートのキャッシュリストを取得する。
|
|
105
|
-
*/
|
|
106
|
-
getList(dateKey: string | null, ignoreExpiry = false) {
|
|
107
|
-
const e = dateKey && this.lists.get(dateKey)
|
|
108
|
-
return e && (ignoreExpiry || this.isValid(e.expiresAt)) ? e.data : null
|
|
109
|
-
},
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Set a report in the cache and optionally sync with the list cache.
|
|
113
|
-
* キャッシュにレポートを設定し、必要に応じてリストキャッシュと同期する。
|
|
114
|
-
*/
|
|
115
|
-
set(report: DailyReportDetail, expiresAt: number, syncList = true) {
|
|
116
|
-
this.reports.set(report.reportHubId, { data: report, expiresAt })
|
|
117
|
-
if (syncList) {
|
|
118
|
-
const dateKey = normalizeBusinessDateKey(report.date)
|
|
119
|
-
const listEntry = dateKey ? this.lists.get(dateKey) : null
|
|
120
|
-
if (listEntry && dateKey) {
|
|
121
|
-
const exists = listEntry.data.some((r) => r.reportHubId === report.reportHubId)
|
|
122
|
-
const data = exists ? listEntry.data.map((r) => (r.reportHubId === report.reportHubId ? report : r)) : [report, ...listEntry.data]
|
|
123
|
-
this.lists.set(dateKey, { ...listEntry, data })
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
this.notify(report.reportHubId)
|
|
127
|
-
},
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Update a cached report with partial data.
|
|
131
|
-
* 部分的なデータでキャッシュされたレポートを更新する。
|
|
132
|
-
*/
|
|
133
|
-
update(id: number, updates: Partial<DailyReportDetail>, strict = true) {
|
|
134
|
-
const entry = this.reports.get(id)
|
|
135
|
-
if (!entry?.data) return
|
|
136
|
-
|
|
137
|
-
if (strict && updates.updatedAt && entry.data.updatedAt) {
|
|
138
|
-
if (new Date(updates.updatedAt).getTime() < new Date(entry.data.updatedAt).getTime()) {
|
|
139
|
-
logger.warn(`[DailyReportCache] Stale update skipped id=${id}`)
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
this.set({ ...entry.data, ...updates } as DailyReportDetail, Math.max(entry.expiresAt, Date.now() + REVIVE_MS))
|
|
144
|
-
},
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Delete a report from the cache and update the corresponding list.
|
|
148
|
-
* キャッシュからレポートを削除し、対応するリストを更新する。
|
|
149
|
-
*/
|
|
150
|
-
delete(id: number, date: string) {
|
|
151
|
-
this.reports.delete(id)
|
|
152
|
-
const dateKey = normalizeBusinessDateKey(date)
|
|
153
|
-
const list = dateKey && this.lists.get(dateKey)
|
|
154
|
-
if (list && dateKey) {
|
|
155
|
-
this.lists.set(dateKey, { ...list, data: list.data.filter((r) => r.reportHubId !== id) })
|
|
156
|
-
}
|
|
157
|
-
this.notify(id)
|
|
158
|
-
},
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Merge server report with cached report to handle optimistic updates and zombies.
|
|
162
|
-
* サーバーレポートとキャッシュレポートをマージして、楽観的更新とゾンビコメントを処理する。
|
|
163
|
-
*/
|
|
164
|
-
merge(serverReport: DailyReportDetail, cachedReport?: DailyReportDetail | null) {
|
|
165
|
-
let report = serverReport
|
|
166
|
-
// Filter zombies
|
|
167
|
-
if (report.postedComments.some((c) => this.recentDeletes.has(c.id))) {
|
|
168
|
-
report = { ...report, postedComments: report.postedComments.filter((c) => !this.recentDeletes.has(c.id)) }
|
|
169
|
-
}
|
|
170
|
-
// Restore missing "my comments"
|
|
171
|
-
if (cachedReport) {
|
|
172
|
-
const serverIds = new Set(report.postedComments.map((c) => c.id))
|
|
173
|
-
const missing = cachedReport.postedComments.filter((c) => !serverIds.has(c.id) && c.isMine && Date.now() - new Date(c.createdAt || 0).getTime() < 10000)
|
|
174
|
-
if (missing.length > 0) {
|
|
175
|
-
report = {
|
|
176
|
-
...report,
|
|
177
|
-
postedComments: [...report.postedComments, ...missing].sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime()),
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return report
|
|
182
|
-
},
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Hydrate the cache with fetched reports, handling comment merges and locks.
|
|
186
|
-
* フェッチされたレポートでキャッシュをハイドレートし、コメントのマージとロックを処理する。
|
|
187
|
-
*/
|
|
188
|
-
cacheMany(reports: DailyReportDetail[], dateKey: string | null, fetchStart: number) {
|
|
189
|
-
// ❗ 0 件でも一覧としてキャッシュする (「その営業日には 1 件も無い」は正当な結果)。
|
|
190
|
-
// 以前の早期 return は末尾の lists.set ごと飛ばしており、空の営業日が毎回
|
|
191
|
-
// フェッチになるうえ、SSE 放送の種まき (set の syncList) の差し込み先も
|
|
192
|
-
// 存在しなかった。意図の記録が無い実装事故だったため除去した
|
|
193
|
-
this.prune()
|
|
194
|
-
const now = Date.now(),
|
|
195
|
-
expiresAt = now + TTL
|
|
196
|
-
for (const [id, t] of this.recentDeletes) if (now - t > 10000) this.recentDeletes.delete(id)
|
|
197
|
-
|
|
198
|
-
const processed = reports.map((r) => {
|
|
199
|
-
// Original logic: If locked, return server data directly (don't update cache, don't merge)
|
|
200
|
-
// ロックされている場合はサーバーデータをそのまま返す(キャッシュ更新せず、マージもしない)
|
|
201
|
-
if (this.locks.has(r.reportHubId)) return r
|
|
202
|
-
|
|
203
|
-
// Original logic: If recently mutated locally, prefer cached data
|
|
204
|
-
// 最近ローカルで変更された場合は、キャッシュデータを優先する
|
|
205
|
-
const lastMut = this.lastMutations.get(r.reportHubId)
|
|
206
|
-
if (lastMut && fetchStart > 0 && lastMut > fetchStart) {
|
|
207
|
-
return this.reports.get(r.reportHubId)?.data ?? r
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const cached = this.reports.get(r.reportHubId)?.data
|
|
211
|
-
const merged = this.merge(r, cached)
|
|
212
|
-
this.set(merged, expiresAt, false)
|
|
213
|
-
return merged
|
|
214
|
-
})
|
|
215
|
-
if (dateKey) this.lists.set(dateKey, { data: processed, expiresAt })
|
|
216
|
-
return processed
|
|
217
|
-
},
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// --- Public API Exports ---
|
|
221
|
-
/**
|
|
222
|
-
* Update the cache for a single report.
|
|
223
|
-
* 単一レポートのキャッシュを更新する。
|
|
224
|
-
*/
|
|
225
|
-
export const writeCache = (r: DailyReportDetail, exp: number, sync = true) => DailyReportCache.set(r, exp, sync)
|
|
226
|
-
/**
|
|
227
|
-
* Get a cached report by ID.
|
|
228
|
-
* ID でキャッシュされたレポートを取得する。
|
|
229
|
-
*/
|
|
230
|
-
export const getCachedReport = (id: number, opts?: { ignoreExpiry?: boolean }) => DailyReportCache.getReport(id, opts?.ignoreExpiry)
|
|
231
|
-
/**
|
|
232
|
-
* Update specific fields of a cached report.
|
|
233
|
-
* キャッシュされたレポートの特定のフィールドを更新する。
|
|
234
|
-
*/
|
|
235
|
-
export const updateDailyReportCache = (id: number, u: Partial<DailyReportDetail>) => DailyReportCache.update(id, u)
|
|
236
|
-
/**
|
|
237
|
-
* Delete a report from the cache.
|
|
238
|
-
* キャッシュからレポートを削除する。
|
|
239
|
-
*/
|
|
240
|
-
export const deleteDailyReportCache = (id: number, d: string) => DailyReportCache.delete(id, d)
|
|
241
|
-
/**
|
|
242
|
-
* Clear all daily report cache store entries.
|
|
243
|
-
* 日報の全キャッシュストアを消去する。
|
|
244
|
-
*/
|
|
245
|
-
export const clearDailyReportCache = () => {
|
|
246
|
-
DailyReportCache.clear()
|
|
247
|
-
// 不在確認の台帳も一緒に捨てる。ユーザー切替でここが残ると、新しいユーザーの
|
|
248
|
-
// 最初の不在確認が最大 10 秒抑止され、その間だけ安全弁が盲目のままになる
|
|
249
|
-
absenceConfirms.clear()
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export const acquireMutationLock = (id: number) => {
|
|
253
|
-
DailyReportCache.locks.add(id)
|
|
254
|
-
DailyReportCache.lastMutations.set(id, Date.now())
|
|
255
|
-
}
|
|
256
|
-
export const releaseMutationLock = (id: number) => {
|
|
257
|
-
DailyReportCache.locks.delete(id)
|
|
258
|
-
DailyReportCache.lastMutations.set(id, Date.now())
|
|
259
|
-
}
|
|
260
|
-
export const registerRecentDeletion = (cId: number) => DailyReportCache.recentDeletes.set(cId, Date.now())
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Subscribe to cache-ready events for a specific report.
|
|
264
|
-
* Returns an unsubscribe function.
|
|
265
|
-
*
|
|
266
|
-
* 特定レポートのキャッシュ書き込みを監視する。
|
|
267
|
-
* 解除関数を返す。
|
|
268
|
-
*/
|
|
269
|
-
export const subscribeCacheReady = (reportHubId: number, callback: () => void): (() => void) => {
|
|
270
|
-
const listener = (id: number) => {
|
|
271
|
-
if (id === reportHubId) {
|
|
272
|
-
callback()
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
DailyReportCache.listeners.add(listener)
|
|
276
|
-
return () => {
|
|
277
|
-
DailyReportCache.listeners.delete(listener)
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
export const applyDailyReportServerUpdates = (_reportHubId: number, report: DailyReportDetail) => {
|
|
282
|
-
DailyReportCache.prune()
|
|
283
|
-
DailyReportCache.set(report, Date.now() + TTL)
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Applies a broadcast (cross-user SSE) report while preserving the viewer's own per-viewer state.
|
|
288
|
-
* 全ユーザー放送 (SSE) の report を、受信者自身の per-viewer 状態を保持したまま反映する処理。
|
|
289
|
-
*
|
|
290
|
-
* SSE の report-create/update/publish が運ぶ isRead/isStarred は**操作した本人の状態**。
|
|
291
|
-
* そのまま書くと受信者の既読/スター表示が他人の状態で上書きされる。共有フィールドだけを
|
|
292
|
-
* 取り込み、per-viewer フィールドは受信者のキャッシュ値を保持する。ローカルに無い日報は
|
|
293
|
-
* 種まきしない (送信者の per-viewer 値で汚すより、次のフェッチが受信者視点の正しい値を
|
|
294
|
-
* 取るのに任せるほうが安全)。
|
|
295
|
-
*
|
|
296
|
-
* ❗ 信頼できる経路 (自分の HTTP 応答・recipient フィルタ済みの自分宛 status-update) は
|
|
297
|
-
* {@link applyDailyReportServerUpdates} を使うこと — そちらは値をそのまま書く。
|
|
298
|
-
*/
|
|
299
|
-
export const applyBroadcastReportUpdate = (report: DailyReportDetail) => {
|
|
300
|
-
DailyReportCache.prune()
|
|
301
|
-
const cached = DailyReportCache.getReport(report.reportHubId, true)
|
|
302
|
-
// per-viewer フィールド (isRead / isStarred / postedComments[].isMine) は送信者視点なので
|
|
303
|
-
// 受信者のキャッシュ値を保持し、未キャッシュなら中立値 (false) で置く。共有フィールドは配信値。
|
|
304
|
-
// ❗ 未キャッシュでも必ず書く: 書かないと鮮度窓内のリストキャッシュに新 id が載らず、
|
|
305
|
-
// 作成/公開された他ユーザーの日報が stale 安全弁でトゥームストーン化され、閲覧中の
|
|
306
|
-
// 受信者の一覧から恒久的に消える (set は既定で syncList=true = リストにも反映する)
|
|
307
|
-
const cachedMine = new Map<number | string, boolean>()
|
|
308
|
-
for (const c of cached?.postedComments ?? []) {
|
|
309
|
-
cachedMine.set(c.id, c.isMine ?? false)
|
|
310
|
-
}
|
|
311
|
-
const postedComments = (report.postedComments ?? []).map((c) => ({ ...c, isMine: cachedMine.get(c.id) ?? false }))
|
|
312
|
-
DailyReportCache.set({ ...report, isRead: cached?.isRead ?? false, isStarred: cached?.isStarred ?? false, postedComments }, Date.now() + TTL)
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// --- Data Loading ---
|
|
316
|
-
/**
|
|
317
|
-
* Fetch a single report by ID.
|
|
318
|
-
* ID で単一のレポートを取得する。
|
|
319
|
-
*/
|
|
320
|
-
const fetchReportById = async (apiBasePath: string, id: number) => {
|
|
321
|
-
// 1. Cache Check
|
|
322
|
-
const cached = DailyReportCache.getReport(id)
|
|
323
|
-
if (cached) return cached
|
|
324
|
-
|
|
325
|
-
// 2. Prevent Duplicate Requests
|
|
326
|
-
const pending = DailyReportCache.pendingReports.get(id)
|
|
327
|
-
if (pending) return pending
|
|
328
|
-
|
|
329
|
-
// 3. Fetch
|
|
330
|
-
const start = Date.now()
|
|
331
|
-
const p = fetch(`${apiBasePath}/report?reportHubId=${id}`, { cache: "no-cache" })
|
|
332
|
-
.then((res) => (res.ok ? res.json() : Promise.reject(`HTTP ${res.status}`)))
|
|
333
|
-
.then((data) => {
|
|
334
|
-
if (data.error) throw new Error(data.error.message)
|
|
335
|
-
const report = data.report as DailyReportDetail
|
|
336
|
-
// 単一レポートの取得時にキャッシュを一括更新する
|
|
337
|
-
return DailyReportCache.cacheMany([report], null, start)[0]
|
|
338
|
-
})
|
|
339
|
-
.finally(() => DailyReportCache.pendingReports.delete(id))
|
|
340
|
-
DailyReportCache.pendingReports.set(id, p)
|
|
341
|
-
return p
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Fetch reports by date key.
|
|
346
|
-
* 日付キーでレポートを取得する。
|
|
347
|
-
*/
|
|
348
|
-
/**
|
|
349
|
-
* 営業日キーごとの不在確認 (強制再取得) の台帳。
|
|
350
|
-
*
|
|
351
|
-
* 安全弁は描画中の行ごとに発火しうるため、無条件に確認すると 1 画面分の行が一斉に
|
|
352
|
-
* 強制再取得を撃つ。営業日単位で間引きつつ、**進行中の確認があれば後続はそれへ合流する** —
|
|
353
|
-
* タイムスタンプだけで諦めると、確認 1 本目の完了直後に作成された行の確認が次の窓まで
|
|
354
|
-
* 塞がれ、その行が stale 安全弁でトゥームストーン化される余地が広がる。
|
|
355
|
-
*/
|
|
356
|
-
const absenceConfirms = new Map<string, { at: number; inFlight: Promise<DailyReportDetail[]> | null }>()
|
|
357
|
-
|
|
358
|
-
/** 不在確認の再実行を抑える間隔 (ms)。 */
|
|
359
|
-
const ABSENCE_CONFIRM_INTERVAL_MS = 10_000
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Confirms a row's absence against fresh server data, throttled per business date.
|
|
363
|
-
* 営業日ごとに間引きながら、行の不在を実データで確認する処理。
|
|
364
|
-
*
|
|
365
|
-
* - 窓内に進行中の確認があればそれへ**合流**する (二重フェッチも切り捨ても起こさない)。
|
|
366
|
-
* - 窓内で確認が完了済みなら null を返す (諦め — 残余窓は仕様書 §5 に明記)。
|
|
367
|
-
*
|
|
368
|
-
* @param apiBasePath API base path. API ベースパス。
|
|
369
|
-
* @param dateKey Business date key. 営業日キー。
|
|
370
|
-
* @returns Fresh list, or null when throttled with no in-flight confirm. 新鮮な一覧 (窓内諦めなら null)。
|
|
371
|
-
*/
|
|
372
|
-
const confirmAbsence = (apiBasePath: string, dateKey: string): Promise<DailyReportDetail[]> | null => {
|
|
373
|
-
const entry = absenceConfirms.get(dateKey)
|
|
374
|
-
const now = Date.now()
|
|
375
|
-
if (entry && now - entry.at < ABSENCE_CONFIRM_INTERVAL_MS) {
|
|
376
|
-
return entry.inFlight
|
|
377
|
-
}
|
|
378
|
-
const inFlight = fetchReportsByDate(apiBasePath, dateKey, { forceRefresh: true })
|
|
379
|
-
const record = { at: now, inFlight: inFlight as Promise<DailyReportDetail[]> | null }
|
|
380
|
-
absenceConfirms.set(dateKey, record)
|
|
381
|
-
// 完了後は「窓内・進行中なし」へ移行する (合流先が無くなり、窓明けまで諦めになる)。
|
|
382
|
-
// ❗ .finally() は**元の拒否理由で再拒否する派生 Promise** を返す。呼び出し側が catch するのは
|
|
383
|
-
// return した元の Promise だけなので、派生側を捨て置くとフェッチ失敗のたびに
|
|
384
|
-
// Unhandled promise rejection が出る。派生鎖だけを飲み込む (元の拒否は呼び出し側へ届く)
|
|
385
|
-
inFlight
|
|
386
|
-
.finally(() => {
|
|
387
|
-
if (absenceConfirms.get(dateKey) === record) {
|
|
388
|
-
record.inFlight = null
|
|
389
|
-
}
|
|
390
|
-
})
|
|
391
|
-
.catch(() => {})
|
|
392
|
-
return inFlight
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const fetchReportsByDate = async (apiBasePath: string, dateKey: string, options: { forceRefresh?: boolean } = {}) => {
|
|
396
|
-
const list = options.forceRefresh ? null : DailyReportCache.getList(dateKey)
|
|
397
|
-
if (list) return list
|
|
398
|
-
|
|
399
|
-
const pending = DailyReportCache.pendingLists.get(dateKey)
|
|
400
|
-
if (pending) return pending
|
|
401
|
-
|
|
402
|
-
const start = Date.now()
|
|
403
|
-
const p = fetch(`${apiBasePath}/business-date?businessDate=${encodeURIComponent(dateKey)}`, { cache: "no-cache" })
|
|
404
|
-
.then((res) => (res.ok ? res.json() : Promise.reject(`HTTP ${res.status}`)))
|
|
405
|
-
.then((data) => {
|
|
406
|
-
if (data.error) throw new Error(data.error.message)
|
|
407
|
-
return DailyReportCache.cacheMany(data.reports ?? [], dateKey, start)
|
|
408
|
-
})
|
|
409
|
-
.finally(() => DailyReportCache.pendingLists.delete(dateKey))
|
|
410
|
-
DailyReportCache.pendingLists.set(dateKey, p)
|
|
411
|
-
return p
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// --- Hooks ---
|
|
415
|
-
export type DailyReportDetailResource = { report: DailyReportDetail | null; error: Error | null; isLoading: boolean; isRefetching: boolean }
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Provides a fetcher-driven daily report detail reader with business-date aware caching.
|
|
419
|
-
* 営業日キャッシュ対応の日報詳細取得フック。
|
|
420
|
-
*/
|
|
421
|
-
export const useDailyReportDetail = (reportHubId: number, businessDate: string | null): DailyReportDetailResource => {
|
|
422
|
-
const { apiBasePath } = useDailyReportConfig()
|
|
423
|
-
const [report, setReport] = useState<DailyReportDetail | null>(() => DailyReportCache.getReport(reportHubId))
|
|
424
|
-
const [error, setError] = useState<Error | null>(null)
|
|
425
|
-
const [isLoading, setIsLoading] = useState(false)
|
|
426
|
-
const [isRefetching, setIsRefetching] = useState(false)
|
|
427
|
-
const reqRef = useRef({ id: reportHubId, token: 0 })
|
|
428
|
-
|
|
429
|
-
// 1. Cache Subscription
|
|
430
|
-
// キャッシュの購読: グローバルな DailyReportCache が更新されたときにローカルの状態を反応的に更新する。
|
|
431
|
-
// 依存関係: [reportHubId] - 対象のレポート ID が変更されたときのみ再購読する。
|
|
432
|
-
// クリーンアップ: メモリリークを防ぐためにリスナーを解除する。
|
|
433
|
-
useEffect(() => {
|
|
434
|
-
const onUpdate = (uid: number) => {
|
|
435
|
-
if (uid === reportHubId) {
|
|
436
|
-
const r = DailyReportCache.getReport(reportHubId, true)
|
|
437
|
-
setReport(r)
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
DailyReportCache.listeners.add(onUpdate)
|
|
441
|
-
return () => {
|
|
442
|
-
DailyReportCache.listeners.delete(onUpdate)
|
|
443
|
-
}
|
|
444
|
-
}, [reportHubId])
|
|
445
|
-
|
|
446
|
-
// 2. Data Loading & Stale Check
|
|
447
|
-
// データのロードと有効期限切れチェック: データのフェッチサイクル、 stale-while-revalidate ロジック、リクエストのデバウンスを管理する。
|
|
448
|
-
// 依存関係: [reportHubId, businessDate, apiBasePath]
|
|
449
|
-
useEffect(() => {
|
|
450
|
-
const dateKey = normalizeBusinessDateKey(businessDate)
|
|
451
|
-
const token = (reqRef.current.token || 0) + 1
|
|
452
|
-
reqRef.current = { id: reportHubId, token }
|
|
453
|
-
|
|
454
|
-
if (reportHubId <= 0) {
|
|
455
|
-
setReport(null)
|
|
456
|
-
setError(reportHubId === 0 ? new Error("Invalid ID") : null)
|
|
457
|
-
setIsLoading(false)
|
|
458
|
-
setIsRefetching(false)
|
|
459
|
-
return
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Cache Check
|
|
463
|
-
let cached = DailyReportCache.getReport(reportHubId, true)
|
|
464
|
-
let isStale = false
|
|
465
|
-
if (cached) {
|
|
466
|
-
isStale = !DailyReportCache.isValid(DailyReportCache.reports.get(reportHubId)?.expiresAt ?? 0)
|
|
467
|
-
} else if (dateKey) {
|
|
468
|
-
const list = DailyReportCache.getList(dateKey, true)
|
|
469
|
-
const found = list?.find((r) => r.reportHubId === reportHubId)
|
|
470
|
-
if (found) {
|
|
471
|
-
cached = found
|
|
472
|
-
isStale = !DailyReportCache.isValid(DailyReportCache.lists.get(dateKey)?.expiresAt ?? 0)
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
setReport(cached)
|
|
476
|
-
|
|
477
|
-
// If cached and valid, we are done
|
|
478
|
-
if (cached && !isStale) {
|
|
479
|
-
setError(null)
|
|
480
|
-
setIsLoading(false)
|
|
481
|
-
setIsRefetching(false)
|
|
482
|
-
return
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
setIsLoading(!cached)
|
|
486
|
-
setIsRefetching(true)
|
|
487
|
-
|
|
488
|
-
// 未キャッシュ (骨組み表示中) の初回取得は即時に走らせる。デバウンスはスクロール連打の
|
|
489
|
-
// 無駄取りを抑える目的なので、スクロール churn の無い初回に効かせると純粋な遅延になる。
|
|
490
|
-
// キャッシュ済み (内容表示中) の stale 再取得だけデバウンスして churn を吸収する
|
|
491
|
-
const debounceMs = cached ? DAILY_REPORT_FETCH_DEBOUNCE_MS : 0
|
|
492
|
-
|
|
493
|
-
const timer = setTimeout(async () => {
|
|
494
|
-
if (reqRef.current.token !== token) return
|
|
495
|
-
try {
|
|
496
|
-
let r: DailyReportDetail | null = null
|
|
497
|
-
if (dateKey) {
|
|
498
|
-
const servedFromCache = DailyReportCache.getList(dateKey) !== null
|
|
499
|
-
const res = await fetchReportsByDate(apiBasePath, dateKey)
|
|
500
|
-
r = res.find((item) => item.reportHubId === reportHubId) ?? null
|
|
501
|
-
// ❗ 一覧キャッシュ (既定 5 分) に無いことは「サーバーに無い」の証拠にならない。
|
|
502
|
-
// キャッシュ構築後に作成/公開された行はここで必ず取りこぼし、`report=null` が
|
|
503
|
-
// そのまま呼び出し側の stale 安全弁の発火条件になる。安全弁は
|
|
504
|
-
// トゥームストーンを刻み常駐ストリームからも行を落とすため、**生きている行が
|
|
505
|
-
// 能動的に削除される**。不在を確定させる前に一度だけ実データで確認する
|
|
506
|
-
if (!r && servedFromCache) {
|
|
507
|
-
const fresh = await confirmAbsence(apiBasePath, dateKey)
|
|
508
|
-
if (fresh) {
|
|
509
|
-
r = fresh.find((item) => item.reportHubId === reportHubId) ?? null
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
} else {
|
|
513
|
-
r = await fetchReportById(apiBasePath, reportHubId)
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
if (reqRef.current.token === token) {
|
|
517
|
-
setReport(r)
|
|
518
|
-
setError(null)
|
|
519
|
-
}
|
|
520
|
-
} catch (e) {
|
|
521
|
-
if (reqRef.current.token === token) setError(e instanceof Error ? e : new Error("Load failed"))
|
|
522
|
-
} finally {
|
|
523
|
-
if (reqRef.current.token === token) {
|
|
524
|
-
setIsLoading(false)
|
|
525
|
-
setIsRefetching(false)
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
}, debounceMs)
|
|
529
|
-
|
|
530
|
-
return () => clearTimeout(timer)
|
|
531
|
-
}, [reportHubId, businessDate, apiBasePath])
|
|
532
|
-
|
|
533
|
-
return { report, error, isLoading, isRefetching }
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Provides a business-date aware prefetch helper.
|
|
538
|
-
* 営業日キャッシュを活用した日報プリフェッチ補助フック。
|
|
539
|
-
*/
|
|
540
|
-
export const useDailyReportPrefetch = () => {
|
|
541
|
-
const { apiBasePath } = useDailyReportConfig()
|
|
542
|
-
return useCallback(
|
|
543
|
-
(date: string | null) => {
|
|
544
|
-
const k = normalizeBusinessDateKey(date)
|
|
545
|
-
if (k && !DailyReportCache.getList(k)) fetchReportsByDate(apiBasePath, k).catch((e) => logger.warn("Prefetch error", k, e))
|
|
546
|
-
},
|
|
547
|
-
[apiBasePath],
|
|
548
|
-
)
|
|
549
|
-
}
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared hook for calculating viewport height of virtual scroll containers.
|
|
3
|
-
* 仮想スクロールコンテナのビューポート高さを計測する共通フック。
|
|
4
|
-
*/
|
|
5
|
-
import { type RefObject, useCallback, useEffect, useState } from "react"
|
|
6
|
-
|
|
7
|
-
type UseDynamicViewportHeightOptions = {
|
|
8
|
-
enableMutationObserver?: boolean
|
|
9
|
-
bottomGap?: number
|
|
10
|
-
minFallbackHeight?: number
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type UseDynamicViewportHeightResult = {
|
|
14
|
-
viewportHeight: number
|
|
15
|
-
updateViewportHeight: () => void
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Computes and updates the viewport height for scroll containers while reacting to layout changes.
|
|
20
|
-
* レイアウト変化に追従しながらスクロールコンテナのビューポート高さを算出して更新する関数。
|
|
21
|
-
*/
|
|
22
|
-
export const useDynamicViewportHeight = (containerRef: RefObject<HTMLDivElement | null>, options?: UseDynamicViewportHeightOptions): UseDynamicViewportHeightResult => {
|
|
23
|
-
const { enableMutationObserver = false, bottomGap = 4, minFallbackHeight = 240 } = options ?? {}
|
|
24
|
-
const [viewportHeight, setViewportHeight] = useState(600)
|
|
25
|
-
|
|
26
|
-
const updateViewportHeight = useCallback(() => {
|
|
27
|
-
if (typeof window === "undefined") {
|
|
28
|
-
return
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const target = containerRef.current
|
|
32
|
-
if (!target) {
|
|
33
|
-
return
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// 要素が非表示の場合は計測しない (display: none など)
|
|
37
|
-
if (target.offsetParent === null && window.getComputedStyle(target).position !== "fixed") {
|
|
38
|
-
return
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const toNumber = (value: string) => Number.parseFloat(value) || 0
|
|
42
|
-
const { top: containerTop } = target.getBoundingClientRect()
|
|
43
|
-
const parentRect = target.parentElement?.getBoundingClientRect()
|
|
44
|
-
const rootStyle = window.getComputedStyle(document.documentElement)
|
|
45
|
-
const footerHeight = toNumber(rootStyle.getPropertyValue("--footer-height"))
|
|
46
|
-
const headerHeight = toNumber(rootStyle.getPropertyValue("--header-height"))
|
|
47
|
-
const scrollbarHeight = toNumber(rootStyle.getPropertyValue("--scrollbar-height"))
|
|
48
|
-
const containerStyle = window.getComputedStyle(target)
|
|
49
|
-
const marginBottom = toNumber(containerStyle.marginBottom)
|
|
50
|
-
const paddingBottom = toNumber(containerStyle.paddingBottom)
|
|
51
|
-
const viewportBottom = window.innerHeight - footerHeight - scrollbarHeight - bottomGap
|
|
52
|
-
const effectiveBottom = parentRect ? Math.min(parentRect.bottom, viewportBottom) : viewportBottom
|
|
53
|
-
const layoutTop = parentRect ? Math.max(parentRect.top, headerHeight) : headerHeight
|
|
54
|
-
const effectiveTop = Math.max(containerTop, layoutTop)
|
|
55
|
-
const availableHeight = effectiveBottom - effectiveTop - marginBottom - paddingBottom
|
|
56
|
-
const safeAvailableHeight = Number.isFinite(availableHeight) ? Math.floor(availableHeight) : 0
|
|
57
|
-
const clampedHeight = safeAvailableHeight > 0 ? safeAvailableHeight : minFallbackHeight
|
|
58
|
-
|
|
59
|
-
if (clampedHeight > 0 && Number.isFinite(clampedHeight)) {
|
|
60
|
-
setViewportHeight((previous) => (Math.abs(previous - clampedHeight) > 1 ? clampedHeight : previous))
|
|
61
|
-
}
|
|
62
|
-
}, [bottomGap, containerRef, minFallbackHeight])
|
|
63
|
-
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
if (typeof window === "undefined") {
|
|
66
|
-
return
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
let frameId: number | null = null
|
|
70
|
-
let timeoutId: number | null = null
|
|
71
|
-
let resizeObserver: ResizeObserver | null = null
|
|
72
|
-
let mutationObserver: MutationObserver | null = null
|
|
73
|
-
const scheduleUpdate = () => {
|
|
74
|
-
if (frameId !== null) {
|
|
75
|
-
window.cancelAnimationFrame(frameId)
|
|
76
|
-
}
|
|
77
|
-
frameId = window.requestAnimationFrame(() => {
|
|
78
|
-
updateViewportHeight()
|
|
79
|
-
})
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// 初期表示直後とわずかな遅延後に計測して安定させる
|
|
83
|
-
scheduleUpdate()
|
|
84
|
-
timeoutId = window.setTimeout(() => {
|
|
85
|
-
scheduleUpdate()
|
|
86
|
-
}, 150)
|
|
87
|
-
|
|
88
|
-
const containerElement = containerRef.current
|
|
89
|
-
if (typeof ResizeObserver !== "undefined" && containerElement) {
|
|
90
|
-
resizeObserver = new ResizeObserver(() => {
|
|
91
|
-
// コンテナと親要素のリサイズ変化に追従する
|
|
92
|
-
scheduleUpdate()
|
|
93
|
-
})
|
|
94
|
-
resizeObserver.observe(containerElement)
|
|
95
|
-
if (containerElement.parentElement) {
|
|
96
|
-
resizeObserver.observe(containerElement.parentElement)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (enableMutationObserver && typeof MutationObserver !== "undefined") {
|
|
101
|
-
mutationObserver = new MutationObserver(() => {
|
|
102
|
-
// ルートのスタイル変更にも追従する
|
|
103
|
-
scheduleUpdate()
|
|
104
|
-
})
|
|
105
|
-
mutationObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["style"] })
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
window.addEventListener("resize", scheduleUpdate)
|
|
109
|
-
|
|
110
|
-
return () => {
|
|
111
|
-
if (frameId !== null) {
|
|
112
|
-
window.cancelAnimationFrame(frameId)
|
|
113
|
-
}
|
|
114
|
-
if (timeoutId !== null) {
|
|
115
|
-
window.clearTimeout(timeoutId)
|
|
116
|
-
}
|
|
117
|
-
window.removeEventListener("resize", scheduleUpdate)
|
|
118
|
-
resizeObserver?.disconnect()
|
|
119
|
-
mutationObserver?.disconnect()
|
|
120
|
-
}
|
|
121
|
-
}, [enableMutationObserver, updateViewportHeight, containerRef])
|
|
122
|
-
|
|
123
|
-
return {
|
|
124
|
-
viewportHeight,
|
|
125
|
-
updateViewportHeight,
|
|
126
|
-
}
|
|
127
|
-
}
|