@requence/table 0.0.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.
@@ -0,0 +1,424 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+
3
+ /* ── Types ──────────────────────────────────────────────────────── */
4
+
5
+ export interface UseTableCacheOptions<T> {
6
+ /** Number of rows per page */
7
+ pageSize: number
8
+ /** Extract unique ID from an item (for update/remove) */
9
+ getItemId: (item: T) => string
10
+ /**
11
+ * Comparator for sort order. Used by `upsert()` to place subscription
12
+ * items at the correct position within cached pages.
13
+ * Return negative if a comes before b, positive if after, 0 if equal.
14
+ */
15
+ compare: (a: T, b: T) => number
16
+ /**
17
+ * Fetch a page of data. Called automatically when the visible range
18
+ * enters an unfetched page. Must return the items and the total count.
19
+ *
20
+ * ── Suspense behavior ──
21
+ * • First call (no cached pages): the returned promise is THROWN
22
+ * → component suspends → Suspense fallback is shown.
23
+ * • Subsequent calls (at least one page cached): non-blocking
24
+ * → cache.loading becomes true → skeleton rows shown for missing indices.
25
+ */
26
+ fetchItems: (
27
+ offset: number,
28
+ limit: number,
29
+ ) => Promise<{ items: T[]; total: number }>
30
+ /**
31
+ * Optional. Fetch just the total count from the server.
32
+ * Called (debounced) when an upsert arrives for an unknown ID,
33
+ * where the cache cannot determine if it's a new item or an
34
+ * update to a never-fetched item.
35
+ * If not provided, the cache falls back to incrementing totalCount
36
+ * (which may drift when items on non-cached pages are updated).
37
+ */
38
+ fetchCount?: () => Promise<number>
39
+ }
40
+
41
+ export interface TableCache<T> {
42
+ /** Current total count (updated by fetch results and mutations) */
43
+ totalCount: number
44
+ /** Get item at absolute index. Returns undefined if page not yet fetched. */
45
+ getItem: (index: number) => T | undefined
46
+ /** Pass this to VirtualTable.onRangeChange — triggers page fetches */
47
+ handleRangeChange: (range: { start: number; end: number }) => void
48
+ /**
49
+ * Upsert an item: if an item with the same ID already exists in a cached
50
+ * page it is updated in-place (no position change, no totalCount change).
51
+ * If the ID is known from a previous fetch but not on a cached page,
52
+ * the item is ignored (it's an update to a non-visible item).
53
+ * Otherwise the item is inserted at its correct sorted position (using
54
+ * `compare`) and — if `fetchCount` was provided — a debounced count
55
+ * re-fetch is triggered to get the authoritative total from the server.
56
+ */
57
+ upsert: (item: T) => void
58
+ /**
59
+ * Remove item by ID. Decrements totalCount.
60
+ * Removes from all cached pages. Invalidates pages after removal point.
61
+ */
62
+ remove: (id: string) => void
63
+ /** Clear all cached pages. Next render will re-suspend. */
64
+ reset: () => void
65
+ /**
66
+ * true when a scroll-triggered page fetch is in-flight.
67
+ * false during the initial Suspense-suspended fetch (Suspense handles that).
68
+ * Use this to show a small loading indicator in the header/footer.
69
+ */
70
+ loading: boolean
71
+ }
72
+
73
+ /* ── Cache state ─────────────────────────────────────────────── */
74
+
75
+ interface CacheState<T> {
76
+ pages: Map<number, T[]>
77
+ totalCount: number
78
+ inflight: Set<number>
79
+ compare: UseTableCacheOptions<T>['compare']
80
+ fetchItems: UseTableCacheOptions<T>['fetchItems']
81
+ fetchCount: UseTableCacheOptions<T>['fetchCount']
82
+ getItemId: UseTableCacheOptions<T>['getItemId']
83
+ promise: Promise<void> | null
84
+ /**
85
+ * Tracks every item ID the cache has ever seen in the current result set.
86
+ * Survives page eviction. Used by `upsert()` to distinguish updates to
87
+ * non-cached items (no count change) from genuinely new items.
88
+ */
89
+ knownIds: Set<string>
90
+ /** Timer handle for the debounced fetchCount call */
91
+ fetchCountTimer: ReturnType<typeof setTimeout> | null
92
+ }
93
+
94
+ /* ── Module-level cache keyed by useId() ─────────────────────── */
95
+ // useId() survives Suspense throws, so the map key is stable.
96
+ // The `deps` string on each entry tracks when the dataset parameters
97
+ // change (sort, filter, scope). A deps mismatch replaces the entry.
98
+
99
+ const cacheMap = new Map<string, CacheState<any>>()
100
+
101
+ function resolveCache<T>(
102
+ id: string,
103
+ options: Pick<
104
+ UseTableCacheOptions<T>,
105
+ 'fetchItems' | 'fetchCount' | 'compare' | 'getItemId'
106
+ >,
107
+ pageSize: number,
108
+ ): CacheState<T> {
109
+ let state = cacheMap.get(id)
110
+ if (!state) {
111
+ state = {
112
+ pages: new Map(),
113
+ totalCount: 0,
114
+ inflight: new Set(),
115
+ compare: options.compare,
116
+ fetchItems: options.fetchItems,
117
+ fetchCount: options.fetchCount,
118
+ getItemId: options.getItemId,
119
+ promise: null,
120
+ knownIds: new Set(),
121
+ fetchCountTimer: null,
122
+ }
123
+ cacheMap.set(id, state)
124
+
125
+ // Kick off initial fetch immediately
126
+ const s = state
127
+ s.promise = options
128
+ .fetchItems(0, pageSize)
129
+ .then((result) => {
130
+ // Only apply if this entry is still current
131
+ if (cacheMap.get(id) === s) {
132
+ s.pages.set(0, result.items)
133
+ s.totalCount = result.total
134
+ for (const item of result.items) {
135
+ s.knownIds.add(s.getItemId(item))
136
+ }
137
+ }
138
+ })
139
+ .then(() => {
140
+ s.promise = null
141
+ })
142
+ .catch((error) => {
143
+ console.error(error)
144
+ throw error
145
+ })
146
+ }
147
+
148
+ state.fetchItems = options.fetchItems
149
+ state.fetchCount = options.fetchCount
150
+ state.compare = options.compare
151
+ state.getItemId = options.getItemId
152
+
153
+ return state
154
+ }
155
+
156
+ /* ── Hook ────────────────────────────────────────────────────── */
157
+
158
+ export function useTableCache<T>(
159
+ key: string,
160
+ options: UseTableCacheOptions<T>,
161
+ ): TableCache<T> {
162
+ const { pageSize, getItemId, compare, fetchItems, fetchCount } = options
163
+
164
+ const [, forceRender] = useState(0)
165
+ const [iteration, setIteration] = useState(0)
166
+ const rerender = useCallback(() => {
167
+ forceRender((c) => c + 1)
168
+ }, [])
169
+
170
+ const activeKey = [key, iteration].join('-')
171
+ // ── Get or create cache (useId() survives Suspense) ────────────
172
+ const currentCache = resolveCache(
173
+ activeKey,
174
+ { fetchItems, fetchCount, compare, getItemId },
175
+ pageSize,
176
+ )
177
+
178
+ // Keep a ref so mutation callbacks always access the current cache
179
+ const cacheRef = useRef(currentCache)
180
+ cacheRef.current = currentCache
181
+
182
+ // ── Suspense: throw if initial fetch is pending ────────────────
183
+ if (currentCache.promise) {
184
+ throw currentCache.promise
185
+ }
186
+
187
+ // ── Cleanup all cache entries for this key on unmount ──────────
188
+ useEffect(
189
+ () => () => {
190
+ for (const k of cacheMap.keys()) {
191
+ if (k.startsWith(key)) {
192
+ cacheMap.delete(k)
193
+ }
194
+ }
195
+ },
196
+ [key],
197
+ )
198
+
199
+ // ── Fetch a page (non-blocking, for scroll-triggered loads) ────
200
+ const fetchPage = useCallback(
201
+ (pageIndex: number) => {
202
+ const c = cacheRef.current
203
+ if (c.pages.has(pageIndex) || c.inflight.has(pageIndex)) {
204
+ return
205
+ }
206
+
207
+ const offset = pageIndex * pageSize
208
+ c.inflight.add(pageIndex)
209
+
210
+ c.fetchItems(offset, pageSize).then((result) => {
211
+ if (cacheRef.current === c) {
212
+ c.pages.set(pageIndex, result.items)
213
+ c.totalCount = result.total
214
+ c.inflight.delete(pageIndex)
215
+ for (const item of result.items) {
216
+ c.knownIds.add(c.getItemId(item))
217
+ }
218
+ rerender()
219
+ }
220
+ })
221
+ },
222
+ [pageSize, rerender],
223
+ )
224
+
225
+ // ── getItem ────────────────────────────────────────────────────
226
+ const getItem = useCallback(
227
+ (index: number): T | undefined => {
228
+ const c = cacheRef.current
229
+ const pageIndex = Math.floor(index / pageSize)
230
+ const page = c.pages.get(pageIndex)
231
+ if (!page) {
232
+ return undefined
233
+ }
234
+ const offsetInPage = index - pageIndex * pageSize
235
+ return page[offsetInPage]
236
+ },
237
+ [pageSize],
238
+ )
239
+
240
+ // ── handleRangeChange ──────────────────────────────────────────
241
+ const handleRangeChange = useCallback(
242
+ (range: { start: number; end: number }) => {
243
+ const c = cacheRef.current
244
+ if (c.totalCount === 0) {
245
+ return
246
+ }
247
+
248
+ const startPage = Math.floor(range.start / pageSize)
249
+ const endPage = Math.floor(
250
+ Math.min(range.end, c.totalCount - 1) / pageSize,
251
+ )
252
+
253
+ for (let p = startPage; p <= endPage; p++) {
254
+ fetchPage(p)
255
+ }
256
+ },
257
+ [pageSize, fetchPage],
258
+ )
259
+
260
+ // ── debounced fetchCount ────────────────────────────────────────
261
+ const debouncedFetchCount = useCallback(() => {
262
+ const c = cacheRef.current
263
+ if (!c.fetchCount) {
264
+ return
265
+ }
266
+
267
+ if (c.fetchCountTimer) {
268
+ clearTimeout(c.fetchCountTimer)
269
+ }
270
+
271
+ c.fetchCountTimer = setTimeout(() => {
272
+ const current = cacheRef.current
273
+ current.fetchCountTimer = null
274
+ current.fetchCount?.().then((total) => {
275
+ if (cacheRef.current === current) {
276
+ current.totalCount = total
277
+ rerender()
278
+ }
279
+ })
280
+ }, 150)
281
+ }, [rerender])
282
+
283
+ // ── upsert (sort-aware insert or in-place update) ──────────────
284
+ const upsert = useCallback(
285
+ (item: T) => {
286
+ const c = cacheRef.current
287
+ const id = getItemId(item)
288
+
289
+ // ── Check for existing item on cached page → update in-place
290
+ for (const [, page] of c.pages) {
291
+ const idx = page.findIndex((p) => getItemId(p) === id)
292
+ if (idx !== -1) {
293
+ page[idx] = item
294
+ rerender()
295
+ return
296
+ }
297
+ }
298
+
299
+ // ── Known ID on a non-cached page → skip (no count change)
300
+ if (c.knownIds.has(id)) {
301
+ return
302
+ }
303
+
304
+ // ── Unknown ID → genuinely new to this result set ──────────
305
+ c.knownIds.add(id)
306
+
307
+ let inserted = false
308
+
309
+ const sortedPageIndices = [...c.pages.keys()].sort((a, b) => a - b)
310
+
311
+ for (const pageIndex of sortedPageIndices) {
312
+ const page = c.pages.get(pageIndex)!
313
+
314
+ if (page.length > 0 && c.compare(item, page[0]) <= 0) {
315
+ page.unshift(item)
316
+ inserted = true
317
+ invalidateAfter(c, pageIndex)
318
+ break
319
+ }
320
+
321
+ if (page.length > 0) {
322
+ const lastItem = page[page.length - 1]
323
+ if (c.compare(item, lastItem) <= 0) {
324
+ let lo = 0
325
+ let hi = page.length
326
+ while (lo < hi) {
327
+ const mid = (lo + hi) >>> 1
328
+ if (c.compare(item, page[mid]) <= 0) {
329
+ hi = mid
330
+ } else {
331
+ lo = mid + 1
332
+ }
333
+ }
334
+ page.splice(lo, 0, item)
335
+ inserted = true
336
+ invalidateAfter(c, pageIndex)
337
+ break
338
+ }
339
+ }
340
+ }
341
+
342
+ if (!inserted) {
343
+ if (sortedPageIndices.length > 0) {
344
+ const firstPageIndex = sortedPageIndices[0]
345
+ const firstPage = c.pages.get(firstPageIndex)!
346
+ if (firstPage.length > 0 && c.compare(item, firstPage[0]) <= 0) {
347
+ firstPage.unshift(item)
348
+ invalidateAfter(c, firstPageIndex)
349
+ }
350
+ }
351
+ }
352
+
353
+ if (c.fetchCount) {
354
+ // Ask the server for the authoritative count (debounced)
355
+ debouncedFetchCount()
356
+ } else {
357
+ // Fallback: optimistic increment (may drift)
358
+ c.totalCount += 1
359
+ }
360
+
361
+ rerender()
362
+ },
363
+ [getItemId, rerender, debouncedFetchCount],
364
+ )
365
+
366
+ // ── remove ─────────────────────────────────────────────────────
367
+ const remove = useCallback(
368
+ (id: string) => {
369
+ const c = cacheRef.current
370
+ let removedFromPage: number | null = null
371
+
372
+ c.knownIds.delete(id)
373
+
374
+ for (const [pageIndex, page] of c.pages) {
375
+ const idx = page.findIndex((item) => getItemId(item) === id)
376
+ if (idx !== -1) {
377
+ page.splice(idx, 1)
378
+ removedFromPage = pageIndex
379
+ break
380
+ }
381
+ }
382
+
383
+ c.totalCount = Math.max(0, c.totalCount - 1)
384
+
385
+ if (removedFromPage !== null) {
386
+ invalidateAfter(c, removedFromPage)
387
+ }
388
+
389
+ rerender()
390
+ },
391
+ [getItemId, rerender],
392
+ )
393
+
394
+ // ── reset ──────────────────────────────────────────────────────
395
+ const reset = useCallback(() => {
396
+ const c = cacheRef.current
397
+ if (c.fetchCountTimer) {
398
+ clearTimeout(c.fetchCountTimer)
399
+ c.fetchCountTimer = null
400
+ }
401
+ setIteration((iteration) => iteration + 1)
402
+ rerender()
403
+ }, [rerender])
404
+
405
+ return {
406
+ totalCount: currentCache.totalCount,
407
+ getItem,
408
+ handleRangeChange,
409
+ upsert,
410
+ remove,
411
+ reset,
412
+ loading: currentCache.inflight.size > 0,
413
+ }
414
+ }
415
+
416
+ /* ── Helpers ──────────────────────────────────────────────────── */
417
+
418
+ function invalidateAfter<T>(cache: CacheState<T>, afterPageIndex: number) {
419
+ for (const key of cache.pages.keys()) {
420
+ if (key > afterPageIndex) {
421
+ cache.pages.delete(key)
422
+ }
423
+ }
424
+ }
@@ -0,0 +1,67 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+
3
+ interface UseColumnWidthsOptions {
4
+ /** Persist widths to localStorage under `columnWidths:{persist}` */
5
+ persist?: string
6
+ }
7
+
8
+ interface RegisterOptions {
9
+ /** Default width. Number for pixels, string for CSS grid values (e.g. '1fr'). */
10
+ defaultValue?: number | string
11
+ /** Store resized widths as fr values instead of pixels. Default: false */
12
+ relative?: boolean
13
+ }
14
+
15
+ function loadPersisted(key: string): Record<string, number | string> | null {
16
+ try {
17
+ const raw = localStorage.getItem(`columnWidths:${key}`)
18
+ return raw ? (JSON.parse(raw) as Record<string, number | string>) : null
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ export function useTableColumnWidths(options?: UseColumnWidthsOptions) {
25
+ const persistKey = options?.persist
26
+
27
+ const [widths, setWidths] = useState<Record<string, number | string>>(
28
+ () => (persistKey ? loadPersisted(persistKey) : null) ?? {},
29
+ )
30
+
31
+ useEffect(() => {
32
+ if (persistKey) {
33
+ localStorage.setItem(`columnWidths:${persistKey}`, JSON.stringify(widths))
34
+ }
35
+ }, [widths, persistKey])
36
+
37
+ const register = useCallback(
38
+ (key: string, registerOptions?: RegisterOptions) => ({
39
+ width: widths[key] ?? registerOptions?.defaultValue,
40
+ resizable: true as const,
41
+ onResizeEnd: (
42
+ width: number,
43
+ _startWidth: number,
44
+ frValue: number,
45
+ ) => {
46
+ if (registerOptions?.relative) {
47
+ setWidths((prev) => ({
48
+ ...prev,
49
+ [key]: `${parseFloat(frValue.toFixed(2))}fr`,
50
+ }))
51
+ } else {
52
+ setWidths((prev) => ({ ...prev, [key]: width }))
53
+ }
54
+ },
55
+ }),
56
+ [widths],
57
+ )
58
+
59
+ const reset = useCallback(() => {
60
+ setWidths({})
61
+ if (persistKey) {
62
+ localStorage.removeItem(`columnWidths:${persistKey}`)
63
+ }
64
+ }, [persistKey])
65
+
66
+ return { register, reset }
67
+ }