cortena-ui 1.4.2 → 1.5.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.
Files changed (133) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +149 -1
  3. package/dist/a2ui/views.js +2 -2
  4. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  5. package/dist/agent-chat/a2ui-block.js +69 -0
  6. package/dist/agent-chat/a2ui-block.js.map +1 -0
  7. package/dist/agent-chat/agui-client.d.ts +40 -0
  8. package/dist/agent-chat/agui-client.js +251 -0
  9. package/dist/agent-chat/agui-client.js.map +1 -0
  10. package/dist/agent-chat/bridge.d.ts +109 -0
  11. package/dist/agent-chat/bridge.js +349 -0
  12. package/dist/agent-chat/bridge.js.map +1 -0
  13. package/dist/agent-chat/session.d.ts +58 -0
  14. package/dist/agent-chat/session.js +249 -0
  15. package/dist/agent-chat/session.js.map +1 -0
  16. package/dist/agent-chat/store.d.ts +67 -0
  17. package/dist/agent-chat/store.js +548 -0
  18. package/dist/agent-chat/store.js.map +1 -0
  19. package/dist/agent-chat/types.d.ts +187 -0
  20. package/dist/agent-chat/types.js +17 -0
  21. package/dist/agent-chat/types.js.map +1 -0
  22. package/dist/agent-chat.d.ts +10 -0
  23. package/dist/agent-chat.js +10 -0
  24. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  25. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  26. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  27. package/dist/components/admin-permissions/context.d.ts +70 -0
  28. package/dist/components/admin-permissions/context.js +258 -0
  29. package/dist/components/admin-permissions/context.js.map +1 -0
  30. package/dist/components/admin-permissions/index.d.ts +10 -0
  31. package/dist/components/admin-permissions/licence.d.ts +15 -0
  32. package/dist/components/admin-permissions/licence.js +78 -0
  33. package/dist/components/admin-permissions/licence.js.map +1 -0
  34. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  35. package/dist/components/admin-permissions/matrix.js +191 -0
  36. package/dist/components/admin-permissions/matrix.js.map +1 -0
  37. package/dist/components/admin-permissions/members.d.ts +18 -0
  38. package/dist/components/admin-permissions/members.js +185 -0
  39. package/dist/components/admin-permissions/members.js.map +1 -0
  40. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  41. package/dist/components/admin-permissions/role-assignment.js +174 -0
  42. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  43. package/dist/components/admin-permissions/roles.d.ts +25 -0
  44. package/dist/components/admin-permissions/roles.js +168 -0
  45. package/dist/components/admin-permissions/roles.js.map +1 -0
  46. package/dist/components/admin-permissions/types.d.ts +152 -0
  47. package/dist/components/admin-permissions/types.js +63 -0
  48. package/dist/components/admin-permissions/types.js.map +1 -0
  49. package/dist/components/agent-chat-popup.d.ts +29 -0
  50. package/dist/components/agent-chat-popup.js +188 -0
  51. package/dist/components/agent-chat-popup.js.map +1 -0
  52. package/dist/components/agent-chat.d.ts +143 -0
  53. package/dist/components/agent-chat.js +578 -0
  54. package/dist/components/agent-chat.js.map +1 -0
  55. package/dist/components/app-shell.d.ts +126 -0
  56. package/dist/components/app-shell.js +297 -0
  57. package/dist/components/app-shell.js.map +1 -0
  58. package/dist/components/badge.d.ts +1 -1
  59. package/dist/components/button-link.js +1 -1
  60. package/dist/components/button.d.ts +2 -2
  61. package/dist/components/checkbox.d.ts +1 -1
  62. package/dist/components/combobox.d.ts +1 -1
  63. package/dist/components/combobox.js +1 -1
  64. package/dist/components/consent-screen.d.ts +65 -0
  65. package/dist/components/consent-screen.js +123 -0
  66. package/dist/components/consent-screen.js.map +1 -0
  67. package/dist/components/data-table/data-table.d.ts +15 -1
  68. package/dist/components/data-table/data-table.js +18 -4
  69. package/dist/components/data-table/data-table.js.map +1 -1
  70. package/dist/components/data-table/index.d.ts +4 -4
  71. package/dist/components/data-table/parts.d.ts +27 -3
  72. package/dist/components/data-table/parts.js +175 -55
  73. package/dist/components/data-table/parts.js.map +1 -1
  74. package/dist/components/data-table/types.d.ts +61 -0
  75. package/dist/components/data-table/use-data-table.js +91 -6
  76. package/dist/components/data-table/use-data-table.js.map +1 -1
  77. package/dist/components/data-table/use-server-source.js +119 -28
  78. package/dist/components/data-table/use-server-source.js.map +1 -1
  79. package/dist/components/help-panel.d.ts +131 -0
  80. package/dist/components/help-panel.js +545 -0
  81. package/dist/components/help-panel.js.map +1 -0
  82. package/dist/components/login-screen.d.ts +127 -0
  83. package/dist/components/login-screen.js +339 -0
  84. package/dist/components/login-screen.js.map +1 -0
  85. package/dist/components/session-guard.d.ts +268 -0
  86. package/dist/components/session-guard.js +632 -0
  87. package/dist/components/session-guard.js.map +1 -0
  88. package/dist/components/toast.d.ts +1 -1
  89. package/dist/core.d.ts +5 -1
  90. package/dist/core.js +11 -7
  91. package/dist/data-table.d.ts +13 -4
  92. package/dist/data-table.js +10 -2
  93. package/dist/hooks/use-cortena-theme.js +49 -3
  94. package/dist/hooks/use-cortena-theme.js.map +1 -1
  95. package/dist/index.d.ts +17 -4
  96. package/dist/index.js +21 -8
  97. package/dist/markdown.d.ts +2 -1
  98. package/dist/markdown.js +2 -1
  99. package/package.json +16 -4
  100. package/src/agent-chat/a2ui-block.ts +118 -0
  101. package/src/agent-chat/agui-client.ts +405 -0
  102. package/src/agent-chat/bridge.ts +433 -0
  103. package/src/agent-chat/session.ts +392 -0
  104. package/src/agent-chat/store.ts +738 -0
  105. package/src/agent-chat/types.ts +213 -0
  106. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  107. package/src/components/admin-permissions/context.tsx +376 -0
  108. package/src/components/admin-permissions/index.tsx +32 -0
  109. package/src/components/admin-permissions/licence.tsx +84 -0
  110. package/src/components/admin-permissions/matrix.tsx +257 -0
  111. package/src/components/admin-permissions/members.tsx +204 -0
  112. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  113. package/src/components/admin-permissions/roles.tsx +169 -0
  114. package/src/components/admin-permissions/types.ts +231 -0
  115. package/src/components/agent-chat-popup.tsx +289 -0
  116. package/src/components/agent-chat.tsx +843 -0
  117. package/src/components/app-shell.tsx +502 -0
  118. package/src/components/consent-screen.tsx +239 -0
  119. package/src/components/data-table/data-table.tsx +36 -0
  120. package/src/components/data-table/index.tsx +6 -1
  121. package/src/components/data-table/parts.tsx +223 -47
  122. package/src/components/data-table/types.ts +68 -0
  123. package/src/components/data-table/use-data-table.ts +152 -4
  124. package/src/components/data-table/use-server-source.ts +150 -12
  125. package/src/components/help-panel.tsx +765 -0
  126. package/src/components/login-screen.tsx +479 -0
  127. package/src/components/session-guard.tsx +1071 -0
  128. package/src/entries/agent-chat.ts +113 -0
  129. package/src/entries/core.ts +8 -0
  130. package/src/entries/data-table.ts +41 -0
  131. package/src/entries/markdown.ts +25 -0
  132. package/src/hooks/use-cortena-theme.ts +63 -4
  133. package/src/index.ts +6 -0
@@ -109,6 +109,52 @@ export type DataTableTable<Row extends RowData> = ReactTable<DataTableFeatures<R
109
109
 
110
110
  export type DataTablePaginationMode = "pages" | "infinite" | "none";
111
111
 
112
+ /* ---------------------------------------------------------------------- */
113
+ /* Freshness (DESIGN-D19 / P-39) */
114
+ /* ---------------------------------------------------------------------- */
115
+
116
+ /**
117
+ * Opt-in polling for a server source, for tables that are *watched* rather
118
+ * than edited — runs in progress, alert lists, a board left open on a wall
119
+ * screen. It renders an on/off switch beside the Refresh button, off until
120
+ * the user turns it on, and it is never a substitute for push: a table that
121
+ * subscribes to a notification stream should call `refresh()` from the stream
122
+ * and leave the interval as the fallback for when the stream is unavailable.
123
+ */
124
+ export interface DataTableAutoRefresh {
125
+ /**
126
+ * Poll interval while the switch is on. Clamped to a 30 s floor; anything
127
+ * shorter is a load problem wearing a freshness costume. @default 60000
128
+ */
129
+ intervalMs?: number;
130
+ /**
131
+ * An extra pause the app owns — a pending mutation, a wizard mid-flight.
132
+ * The tab being hidden, an open inline editor and a live selection are
133
+ * already handled by the component.
134
+ */
135
+ paused?: boolean;
136
+ }
137
+
138
+ /** Why the interval is not running even though the switch is on. */
139
+ export type DataTableRefreshPause = "hidden" | "editing" | "selection" | "mutation" | "app";
140
+
141
+ /** The auto-refresh half of the toolbar's freshness controls. */
142
+ export interface DataTableAutoRefreshState {
143
+ /** The user's switch, remembered per `stateKey`. False until they turn it on. */
144
+ enabled: boolean;
145
+ setEnabled: (on: boolean) => void;
146
+ /** `enabled` and nothing is pausing it: the interval is really running. */
147
+ active: boolean;
148
+ /** Set while `enabled` but paused, so the UI can say why. */
149
+ pausedBy: DataTableRefreshPause | null;
150
+ /** The interval in force, including error back-off. */
151
+ intervalMs: number;
152
+ /** The configured interval after the 30 s clamp. */
153
+ baseIntervalMs: number;
154
+ /** True while consecutive failures have stretched the interval. */
155
+ backingOff: boolean;
156
+ }
157
+
112
158
  export interface UseDataTableOptions<Row extends RowData> {
113
159
  columns: ReadonlyArray<DataTableColumnDef<Row, any>>;
114
160
  dataSource: DataSource<Row>;
@@ -147,6 +193,19 @@ export interface UseDataTableOptions<Row extends RowData> {
147
193
  onRowSelectionChange?: (rows: Row[]) => void;
148
194
  /** Debounce applied to the search box before it reaches a server source. @default 250 */
149
195
  searchDebounceMs?: number;
196
+ /**
197
+ * Turns on the auto-refresh switch beside the Refresh button (server
198
+ * sources only). Off by default; `stateKey` is required with it, because
199
+ * the user's choice is remembered per table.
200
+ */
201
+ autoRefresh?: DataTableAutoRefresh;
202
+ /**
203
+ * Identity of this table for anything remembered between visits. Required
204
+ * when `autoRefresh` is set: the choice is stored under
205
+ * `cortena.data-table.auto-refresh.<stateKey>`, so turning polling on for
206
+ * the runs list does not turn it on for every other table.
207
+ */
208
+ stateKey?: string;
150
209
  }
151
210
 
152
211
  export interface DataTableEditingCell {
@@ -173,6 +232,15 @@ export interface DataTableInstance<Row extends RowData> {
173
232
  hasNextPage: boolean;
174
233
  /** Search text the server last received (debounced), for consumers rendering their own toolbar. */
175
234
  searchQuery: string;
235
+ /**
236
+ * `Date.now()` of the last successful server response, for the "Updated
237
+ * hh:mm" stamp. Null before the first one, and for a client source.
238
+ */
239
+ lastUpdatedAt: number | null;
240
+ /** `Date.now()` of the last failed one; the toolbar shows it briefly. */
241
+ lastErrorAt: number | null;
242
+ /** Null unless `autoRefresh` was configured on a server source. */
243
+ autoRefresh: DataTableAutoRefreshState | null;
176
244
  editing: {
177
245
  cell: DataTableEditingCell | null;
178
246
  start: (cell: DataTableEditingCell) => void;
@@ -5,6 +5,7 @@ import {
5
5
  type ColumnFiltersState,
6
6
  type PaginationState,
7
7
  type RowData,
8
+ type RowSelectionState,
8
9
  type SortingState,
9
10
  } from "@tanstack/react-table";
10
11
  import * as React from "react";
@@ -12,9 +13,11 @@ import { createColumnHelper, dataTableFeatures } from "./features";
12
13
  import { expandColumn, selectColumn } from "./system-columns";
13
14
  import type {
14
15
  DataSource,
16
+ DataTableAutoRefreshState,
15
17
  DataTableColumnDef,
16
18
  DataTableEditingCell,
17
19
  DataTableInstance,
20
+ DataTableRefreshPause,
18
21
  DataTableRow,
19
22
  DataTableTable,
20
23
  TableQuery,
@@ -24,6 +27,66 @@ import { useServerSource } from "./use-server-source";
24
27
 
25
28
  const EMPTY_ROWS: never[] = [];
26
29
 
30
+ /** The floor on `autoRefresh.intervalMs`; anything shorter is load, not freshness. */
31
+ export const AUTO_REFRESH_MIN_MS = 30_000;
32
+ /** Used when `autoRefresh` names no interval. */
33
+ export const AUTO_REFRESH_DEFAULT_MS = 60_000;
34
+ /** `cortena.data-table.auto-refresh.<stateKey>` holds `"on"` or `"off"`. */
35
+ export const AUTO_REFRESH_STORAGE_PREFIX = "cortena.data-table.auto-refresh.";
36
+
37
+ /** One warning per process, not one per render. */
38
+ const warned = { missingStateKey: false };
39
+
40
+ function readStoredFlag(key: string): boolean {
41
+ try {
42
+ return window.localStorage.getItem(key) === "on";
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The user's auto-refresh choice, remembered per table.
50
+ *
51
+ * It starts off and is read from storage in an effect rather than in the
52
+ * initial state, so a server-rendered table and its first client render agree
53
+ * — a switch that hydrates in the opposite position is a React mismatch and,
54
+ * worse, a table that starts polling before the page is interactive.
55
+ */
56
+ function usePersistedFlag(storageKey: string | null): [boolean, (on: boolean) => void] {
57
+ const [on, setOn] = React.useState(false);
58
+ React.useEffect(() => {
59
+ setOn(storageKey ? readStoredFlag(storageKey) : false);
60
+ }, [storageKey]);
61
+ const set = React.useCallback(
62
+ (next: boolean) => {
63
+ setOn(next);
64
+ if (!storageKey) return;
65
+ try {
66
+ window.localStorage.setItem(storageKey, next ? "on" : "off");
67
+ } catch {
68
+ /* Private mode, or storage full: the switch still works for this visit. */
69
+ }
70
+ },
71
+ [storageKey],
72
+ );
73
+ return [on, set];
74
+ }
75
+
76
+ const subscribeVisibility = (onChange: () => void) => {
77
+ document.addEventListener("visibilitychange", onChange);
78
+ return () => document.removeEventListener("visibilitychange", onChange);
79
+ };
80
+
81
+ /** True while the tab is in the background. Polling a tab nobody is looking at is a battery bill. */
82
+ function useDocumentHidden(): boolean {
83
+ return React.useSyncExternalStore(
84
+ subscribeVisibility,
85
+ () => document.visibilityState === "hidden",
86
+ () => false,
87
+ );
88
+ }
89
+
27
90
  function useDebounced<T>(value: T, ms: number): T {
28
91
  const [debounced, setDebounced] = React.useState(value);
29
92
  React.useEffect(() => {
@@ -73,6 +136,8 @@ export function useDataTable<Row extends RowData>(
73
136
  onCellCommit,
74
137
  onRowSelectionChange,
75
138
  searchDebounceMs = 250,
139
+ autoRefresh,
140
+ stateKey,
76
141
  } = options;
77
142
 
78
143
  const isServer = dataSource.kind === "server";
@@ -89,6 +154,13 @@ export function useDataTable<Row extends RowData>(
89
154
  const [pagination, setPagination] = React.useState<PaginationState>(
90
155
  initialState?.pagination ?? { pageIndex: 0, pageSize },
91
156
  );
157
+ // Selection and the open editor are lifted for the same reason the query
158
+ // slices are: auto-refresh has to know about them before the query for this
159
+ // render is declared, and `table.state` only exists after `useTable`.
160
+ const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(
161
+ initialState?.rowSelection ?? {},
162
+ );
163
+ const [editingCell, setEditingCell] = React.useState<DataTableEditingCell | null>(null);
92
164
 
93
165
  const searchQuery = useDebounced(globalFilter, isServer ? searchDebounceMs : 0);
94
166
 
@@ -116,7 +188,44 @@ export function useDataTable<Row extends RowData>(
116
188
  [searchQuery, columnFilters, sorting, pagination.pageIndex, pagination.pageSize],
117
189
  );
118
190
 
119
- const server = useServerSource<Row>(isServer ? dataSource : undefined, query, paginationMode);
191
+ /* Freshness (DESIGN-D19 / P-39) -------------------------------------- */
192
+
193
+ const wantsAuto = isServer && autoRefresh !== undefined;
194
+ React.useEffect(() => {
195
+ if (!wantsAuto || stateKey || warned.missingStateKey) return;
196
+ warned.missingStateKey = true;
197
+ console.warn(
198
+ "[cortena-ui] DataTable: `autoRefresh` needs a `stateKey`. Without one the " +
199
+ "user's on/off choice cannot be remembered between visits, and two tables " +
200
+ "on one screen would share the same setting.",
201
+ );
202
+ }, [wantsAuto, stateKey]);
203
+ const storageKey = wantsAuto && stateKey ? AUTO_REFRESH_STORAGE_PREFIX + stateKey : null;
204
+ const [autoEnabled, setAutoEnabled] = usePersistedFlag(storageKey);
205
+ const hidden = useDocumentHidden();
206
+ const baseIntervalMs = Math.max(
207
+ autoRefresh?.intervalMs ?? AUTO_REFRESH_DEFAULT_MS,
208
+ AUTO_REFRESH_MIN_MS,
209
+ );
210
+ // The polite rules, in the order a user would explain them. Each one is a
211
+ // reason the interval is not running, and the toolbar says which.
212
+ const pausedBy: DataTableRefreshPause | null = hidden
213
+ ? "hidden"
214
+ : editingCell
215
+ ? "editing"
216
+ : Object.keys(rowSelection).length > 0
217
+ ? "selection"
218
+ : autoRefresh?.paused
219
+ ? "app"
220
+ : null;
221
+ const autoActive = wantsAuto && autoEnabled && pausedBy === null;
222
+
223
+ const server = useServerSource<Row>(
224
+ isServer ? dataSource : undefined,
225
+ query,
226
+ paginationMode,
227
+ wantsAuto ? { intervalMs: baseIntervalMs, active: autoActive } : undefined,
228
+ );
120
229
 
121
230
  const data: ReadonlyArray<Row> = isServer ? server.rows : dataSource.rows;
122
231
 
@@ -147,11 +256,12 @@ export function useDataTable<Row extends RowData>(
147
256
  getRowId,
148
257
  getSubRows,
149
258
  initialState,
150
- state: { globalFilter, columnFilters, sorting, pagination },
259
+ state: { globalFilter, columnFilters, sorting, pagination, rowSelection },
151
260
  onGlobalFilterChange: setGlobalFilter,
152
261
  onColumnFiltersChange: setColumnFilters,
153
262
  onSortingChange: setSorting,
154
263
  onPaginationChange: setPagination,
264
+ onRowSelectionChange: setRowSelection,
155
265
  manualFiltering: isServer,
156
266
  manualSorting: isServer,
157
267
  manualPagination: isServer || paginationMode !== "pages",
@@ -173,7 +283,6 @@ export function useDataTable<Row extends RowData>(
173
283
  });
174
284
 
175
285
  // Selection callback with original rows, for consumers that keep selection outside.
176
- const rowSelection = table.state.rowSelection;
177
286
  const onSelectionRef = React.useRef(onRowSelectionChange);
178
287
  onSelectionRef.current = onRowSelectionChange;
179
288
  const firstSelection = React.useRef(true);
@@ -186,7 +295,6 @@ export function useDataTable<Row extends RowData>(
186
295
  // eslint-disable-next-line react-hooks/exhaustive-deps
187
296
  }, [rowSelection]);
188
297
 
189
- const [editingCell, setEditingCell] = React.useState<DataTableEditingCell | null>(null);
190
298
  const commit = React.useCallback(
191
299
  async (row: DataTableRow<Row>, columnId: string, value: unknown) => {
192
300
  const meta = table.getColumn(columnId)?.columnDef.meta;
@@ -205,6 +313,43 @@ export function useDataTable<Row extends RowData>(
205
313
  [editingCell, commit],
206
314
  );
207
315
 
316
+ // The stamp keeps the last real response time: with keepPreviousData a page
317
+ // change puts a placeholder on screen whose `dataUpdatedAt` is zero, and the
318
+ // stamp must not blink out while the next page loads.
319
+ const lastUpdatedRef = React.useRef<number | null>(null);
320
+ if (server.lastUpdatedAt && server.lastUpdatedAt > (lastUpdatedRef.current ?? 0)) {
321
+ lastUpdatedRef.current = server.lastUpdatedAt;
322
+ }
323
+ const lastUpdatedAt = lastUpdatedRef.current;
324
+
325
+ const autoState = React.useMemo<DataTableAutoRefreshState | null>(
326
+ () =>
327
+ wantsAuto
328
+ ? {
329
+ enabled: autoEnabled,
330
+ setEnabled: setAutoEnabled,
331
+ active: autoActive && !server.mutating,
332
+ // A mutation in flight is the one pause only the query layer can
333
+ // see; it is folded in here so the toolbar can still say why.
334
+ pausedBy: autoEnabled ? (pausedBy ?? (server.mutating ? "mutation" : null)) : null,
335
+ intervalMs: server.intervalMs === false ? baseIntervalMs : server.intervalMs,
336
+ baseIntervalMs,
337
+ backingOff: server.failures > 0,
338
+ }
339
+ : null,
340
+ [
341
+ wantsAuto,
342
+ autoEnabled,
343
+ setAutoEnabled,
344
+ autoActive,
345
+ pausedBy,
346
+ server.mutating,
347
+ server.intervalMs,
348
+ server.failures,
349
+ baseIntervalMs,
350
+ ],
351
+ );
352
+
208
353
  const total = isServer
209
354
  ? (server.total ?? 0)
210
355
  : paginationMode === "pages"
@@ -224,6 +369,9 @@ export function useDataTable<Row extends RowData>(
224
369
  fetchNextPage: server.fetchNextPage,
225
370
  hasNextPage: server.hasNextPage,
226
371
  searchQuery,
372
+ lastUpdatedAt,
373
+ lastErrorAt: server.lastErrorAt,
374
+ autoRefresh: autoState,
227
375
  editing,
228
376
  };
229
377
  }
@@ -4,6 +4,7 @@ import {
4
4
  keepPreviousData,
5
5
  QueryClientContext,
6
6
  useInfiniteQuery,
7
+ useIsMutating,
7
8
  useQuery,
8
9
  } from "@tanstack/react-query";
9
10
  import * as React from "react";
@@ -18,6 +19,62 @@ export interface ServerSourceState<Row> {
18
19
  refetch: () => void;
19
20
  fetchNextPage: () => void;
20
21
  hasNextPage: boolean;
22
+ /** `Date.now()` of the last successful response; null before the first. */
23
+ lastUpdatedAt: number | null;
24
+ /** `Date.now()` of the last failure; null before the first. */
25
+ lastErrorAt: number | null;
26
+ /** Consecutive failures. Doubles the interval; zero again after a success. */
27
+ failures: number;
28
+ /** The interval actually scheduled, false when nothing is polling. */
29
+ intervalMs: number | false;
30
+ /** A mutation is in flight on the query client, so polling is held. */
31
+ mutating: boolean;
32
+ }
33
+
34
+ /** What the interval needs from the caller; the pauses it cannot see are already applied. */
35
+ export interface ServerSourceAutoRefresh {
36
+ /** Base interval, already clamped to the 30 s floor. */
37
+ intervalMs: number;
38
+ /** The switch is on and nothing the table can see is pausing it. */
39
+ active: boolean;
40
+ }
41
+
42
+ /** Errors stretch the interval rather than hammering a failing endpoint. */
43
+ export const AUTO_REFRESH_MAX_MS = 300_000;
44
+
45
+ function backoffOf(base: number, failures: number): number {
46
+ return Math.min(base * 2 ** failures, AUTO_REFRESH_MAX_MS);
47
+ }
48
+
49
+ export interface RequestStamps {
50
+ ok: number | null;
51
+ err: number | null;
52
+ }
53
+
54
+ /**
55
+ * Consecutive failures, counted from the timestamps the source reports: one
56
+ * more for every new error, back to zero on any success.
57
+ *
58
+ * It is state, and it is read *before* the query is declared, so the interval
59
+ * handed to `refetchInterval` on the next render already carries the back-off.
60
+ * The timestamps only exist after the query, hence the ref: the effect runs
61
+ * after the commit, by which time the render has filled it in.
62
+ */
63
+ function useFailureCount(stamps: React.RefObject<RequestStamps>): number {
64
+ const [failures, setFailures] = React.useState(0);
65
+ const seen = React.useRef<RequestStamps>({ ok: null, err: null });
66
+ React.useEffect(() => {
67
+ const { ok, err } = stamps.current;
68
+ if (err !== null && err !== seen.current.err) {
69
+ seen.current.err = err;
70
+ setFailures((n) => n + 1);
71
+ }
72
+ if (ok !== null && ok !== seen.current.ok) {
73
+ seen.current.ok = ok;
74
+ setFailures(0);
75
+ }
76
+ });
77
+ return failures;
21
78
  }
22
79
 
23
80
  const EMPTY: never[] = [];
@@ -48,30 +105,43 @@ export function useServerSource<Row>(
48
105
  source: ServerSource<Row> | undefined,
49
106
  query: TableQuery,
50
107
  mode: "pages" | "infinite" | "none",
108
+ auto?: ServerSourceAutoRefresh,
51
109
  ): ServerSourceState<Row> {
52
110
  const client = React.useContext(QueryClientContext);
53
111
  const [strategy] = React.useState<"query" | "local">(() => (client ? "query" : "local"));
54
112
  // Hook order is stable for the life of the component because `strategy` is.
55
113
  return strategy === "query"
56
- ? useQueryStrategy(source, query, mode)
57
- : useLocalStrategy(source, query, mode);
114
+ ? useQueryStrategy(source, query, mode, auto)
115
+ : useLocalStrategy(source, query, mode, auto);
58
116
  }
59
117
 
60
118
  function useQueryStrategy<Row>(
61
119
  source: ServerSource<Row> | undefined,
62
120
  query: TableQuery,
63
121
  mode: "pages" | "infinite" | "none",
122
+ auto?: ServerSourceAutoRefresh,
64
123
  ): ServerSourceState<Row> {
65
124
  const id = React.useId();
66
125
  const base = source?.queryKey ?? ["cortena-data-table", id];
67
126
  const infinite = mode === "infinite";
68
127
  const fetch = source?.fetch;
69
128
 
129
+ // A mutation in flight is the fifth pause: refetching over a save the user
130
+ // has just fired shows them the row as it was a moment before it changed.
131
+ const mutating = useIsMutating() > 0;
132
+ // The interval and the Refresh button are the same query: `refetchInterval`
133
+ // and `refetch()` on one `useQuery`, never a second fetch path beside it.
134
+ const stamps = React.useRef<RequestStamps>({ ok: null, err: null });
135
+ const failures = useFailureCount(stamps);
136
+ const refetchInterval =
137
+ auto && auto.active && !mutating ? backoffOf(auto.intervalMs, failures) : (false as const);
138
+
70
139
  const page = useQuery({
71
140
  queryKey: [...base, "page", query],
72
141
  queryFn: ({ signal }) => fetch!(query, signal),
73
142
  enabled: Boolean(fetch) && !infinite,
74
143
  placeholderData: keepPreviousData,
144
+ refetchInterval: infinite ? false : refetchInterval,
75
145
  });
76
146
 
77
147
  const scope = scopeOf(query);
@@ -85,8 +155,14 @@ function useQueryStrategy<Row>(
85
155
  },
86
156
  enabled: Boolean(fetch) && infinite,
87
157
  placeholderData: keepPreviousData,
158
+ refetchInterval: infinite ? refetchInterval : false,
88
159
  });
89
160
 
161
+ const live = infinite ? pages : page;
162
+ const lastUpdatedAt = live.dataUpdatedAt || null;
163
+ const lastErrorAt = live.errorUpdatedAt || null;
164
+ stamps.current = { ok: lastUpdatedAt, err: lastErrorAt };
165
+
90
166
  const flatRows = React.useMemo(
91
167
  () => (pages.data ? pages.data.pages.flatMap((p) => p.rows) : EMPTY),
92
168
  [pages.data],
@@ -102,6 +178,11 @@ function useQueryStrategy<Row>(
102
178
  refetch: () => {},
103
179
  fetchNextPage: () => {},
104
180
  hasNextPage: false,
181
+ lastUpdatedAt: null,
182
+ lastErrorAt: null,
183
+ failures: 0,
184
+ intervalMs: false,
185
+ mutating,
105
186
  };
106
187
  }
107
188
 
@@ -117,6 +198,11 @@ function useQueryStrategy<Row>(
117
198
  if (pages.hasNextPage && !pages.isFetchingNextPage) void pages.fetchNextPage();
118
199
  },
119
200
  hasNextPage: pages.hasNextPage,
201
+ lastUpdatedAt,
202
+ lastErrorAt,
203
+ failures,
204
+ intervalMs: refetchInterval,
205
+ mutating,
120
206
  };
121
207
  }
122
208
 
@@ -129,6 +215,11 @@ function useQueryStrategy<Row>(
129
215
  refetch: () => void page.refetch(),
130
216
  fetchNextPage: () => {},
131
217
  hasNextPage: false,
218
+ lastUpdatedAt,
219
+ lastErrorAt,
220
+ failures,
221
+ intervalMs: refetchInterval,
222
+ mutating,
132
223
  };
133
224
  }
134
225
 
@@ -136,14 +227,19 @@ interface LocalState<Row> {
136
227
  pages: TableResult<Row>[];
137
228
  scopeKey: string;
138
229
  page: number;
230
+ /** The refresh generation this state answers; `tick` moving on means stale. */
231
+ tick: number;
139
232
  isFetching: boolean;
140
233
  error: Error | null;
234
+ updatedAt: number | null;
235
+ errorAt: number | null;
141
236
  }
142
237
 
143
238
  function useLocalStrategy<Row>(
144
239
  source: ServerSource<Row> | undefined,
145
240
  query: TableQuery,
146
241
  mode: "pages" | "infinite" | "none",
242
+ auto?: ServerSourceAutoRefresh,
147
243
  ): ServerSourceState<Row> {
148
244
  const infinite = mode === "infinite";
149
245
  const fetch = source?.fetch;
@@ -157,15 +253,26 @@ function useLocalStrategy<Row>(
157
253
  pages: [],
158
254
  scopeKey,
159
255
  page: -1,
256
+ tick: -1,
160
257
  isFetching: false,
161
258
  error: null,
259
+ updatedAt: null,
260
+ errorAt: null,
162
261
  });
163
262
 
164
263
  const loadedForScope = state.scopeKey === scopeKey ? state.pages : EMPTY;
165
- const targetPage = infinite ? loadedForScope.length : query.page;
166
- const needs = infinite
167
- ? loadedForScope.length < wantedPages
168
- : !(state.scopeKey === scopeKey && state.page === query.page && state.pages.length > 0);
264
+ // A refresh — the button or the interval — bumps `tick`; the rows already on
265
+ // screen stay there until the new ones arrive, exactly as keepPreviousData
266
+ // behaves under a provider. Nothing about sort, filters, page or selection
267
+ // is touched, because none of them live here.
268
+ const stale = state.tick !== tick;
269
+ const restart = stale || state.scopeKey !== scopeKey;
270
+ const targetPage = infinite ? (restart ? 0 : loadedForScope.length) : query.page;
271
+ const needs =
272
+ stale ||
273
+ (infinite
274
+ ? loadedForScope.length < wantedPages
275
+ : !(state.scopeKey === scopeKey && state.page === query.page && state.pages.length > 0));
169
276
  const requestKey = `${scopeKey}|${targetPage}|${tick}`;
170
277
 
171
278
  // The consumer's `fetch` is usually an inline arrow — `fetch: q => api.list(q)`
@@ -188,16 +295,27 @@ function useLocalStrategy<Row>(
188
295
  (result) => {
189
296
  if (controller.signal.aborted) return;
190
297
  setState((s) => ({
191
- pages: infinite && s.scopeKey === scopeKey ? [...s.pages, result] : [result],
298
+ pages: infinite && !restart && s.scopeKey === scopeKey ? [...s.pages, result] : [result],
192
299
  scopeKey,
193
300
  page: targetPage,
301
+ tick,
194
302
  isFetching: false,
195
303
  error: null,
304
+ updatedAt: Date.now(),
305
+ errorAt: s.errorAt,
196
306
  }));
197
307
  },
198
308
  (err: unknown) => {
199
309
  if (controller.signal.aborted) return;
200
- setState((s) => ({ ...s, isFetching: false, error: toError(err) }));
310
+ // `tick` is recorded on the failure too: without it the request stays
311
+ // "wanted" and a single dead endpoint is retried on every render.
312
+ setState((s) => ({
313
+ ...s,
314
+ tick,
315
+ isFetching: false,
316
+ error: toError(err),
317
+ errorAt: Date.now(),
318
+ }));
201
319
  },
202
320
  );
203
321
  return () => controller.abort();
@@ -206,6 +324,19 @@ function useLocalStrategy<Row>(
206
324
  // eslint-disable-next-line react-hooks/exhaustive-deps
207
325
  }, [hasFetch, requestKey, needs]);
208
326
 
327
+ const stamps = React.useRef<RequestStamps>({ ok: null, err: null });
328
+ stamps.current = { ok: state.updatedAt, err: state.errorAt };
329
+ const failures = useFailureCount(stamps);
330
+ const intervalMs = auto && auto.active ? backoffOf(auto.intervalMs, failures) : (false as const);
331
+
332
+ // Without a QueryClientProvider there is no `refetchInterval` to lean on, so
333
+ // the same `tick` the button bumps is bumped on a timer. Still one path.
334
+ React.useEffect(() => {
335
+ if (intervalMs === false) return;
336
+ const id = setInterval(() => setTick((t) => t + 1), intervalMs);
337
+ return () => clearInterval(id);
338
+ }, [intervalMs]);
339
+
209
340
  // Like keepPreviousData: the last result stays on screen while the next loads.
210
341
  const rows = React.useMemo(
211
342
  () => (infinite ? state.pages.flatMap((p) => p.rows) : (state.pages[0]?.rows ?? EMPTY)),
@@ -224,6 +355,11 @@ function useLocalStrategy<Row>(
224
355
  refetch: () => {},
225
356
  fetchNextPage: () => {},
226
357
  hasNextPage: false,
358
+ lastUpdatedAt: null,
359
+ lastErrorAt: null,
360
+ failures: 0,
361
+ intervalMs: false,
362
+ mutating: false,
227
363
  };
228
364
  }
229
365
 
@@ -233,13 +369,15 @@ function useLocalStrategy<Row>(
233
369
  isLoading: state.pages.length === 0 && !state.error,
234
370
  isFetching: state.isFetching,
235
371
  error: state.error,
236
- refetch: () => {
237
- setState((s) => ({ ...s, pages: [], page: -1, error: null }));
238
- setTick((t) => t + 1);
239
- },
372
+ refetch: () => setTick((t) => t + 1),
240
373
  fetchNextPage: () => {
241
374
  if (hasNextPage && !state.isFetching) setWanted({ scopeKey, n: loadedForScope.length + 1 });
242
375
  },
243
376
  hasNextPage,
377
+ lastUpdatedAt: state.updatedAt,
378
+ lastErrorAt: state.errorAt,
379
+ failures,
380
+ intervalMs,
381
+ mutating: false,
244
382
  };
245
383
  }