@cosmicdrift/kumiko-renderer-web 0.221.0 → 0.222.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/__tests__/nav-tree.test.tsx +6 -14
- package/src/__tests__/primitives.test.tsx +29 -1
- package/src/app/plain-content-editor.tsx +6 -2
- package/src/primitives/__tests__/date-parse.test.ts +6 -3
- package/src/primitives/date-parse.ts +2 -0
- package/src/primitives/index.tsx +7 -5
- package/src/widgets/__tests__/infinity-list.test.tsx +17 -9
- package/src/widgets/drawer.tsx +7 -3
- package/src/widgets/infinity-list.tsx +41 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.222.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.222.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.222.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.222.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"jsdom": "^29.1.1",
|
|
66
66
|
"tailwindcss": "^4.3.0",
|
|
67
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.222.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -360,22 +360,11 @@ describe("NavTree", () => {
|
|
|
360
360
|
order: 20,
|
|
361
361
|
icon: "shield-check",
|
|
362
362
|
},
|
|
363
|
+
{ id: "cap-list", label: "Caps", screen: "cap-list", order: 60, icon: "gauge" },
|
|
363
364
|
],
|
|
364
365
|
} as FeatureSchema;
|
|
365
366
|
const { container } = render(<NavTree schema={schema} />);
|
|
366
|
-
expectNavIcons(container, ["send", "shield-check"]);
|
|
367
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test("gauge (cap-list nav) resolves to an icon without missing-icon path", () => {
|
|
371
|
-
const schema = {
|
|
372
|
-
featureName: "cap-counter",
|
|
373
|
-
entities: {},
|
|
374
|
-
screens: [{ id: "cap-list", type: "entityList", entity: "x", columns: [] }],
|
|
375
|
-
navs: [{ id: "cap-list", label: "Caps", screen: "cap-list", order: 60, icon: "gauge" }],
|
|
376
|
-
} as FeatureSchema;
|
|
377
|
-
const { container } = render(<NavTree schema={schema} />);
|
|
378
|
-
expectNavIcons(container, ["gauge"]);
|
|
367
|
+
expectNavIcons(container, ["send", "shield-check", "gauge"]);
|
|
379
368
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
380
369
|
});
|
|
381
370
|
|
|
@@ -383,7 +372,10 @@ describe("NavTree", () => {
|
|
|
383
372
|
const schema = {
|
|
384
373
|
featureName: "showcase",
|
|
385
374
|
entities: {},
|
|
386
|
-
screens: [
|
|
375
|
+
screens: [
|
|
376
|
+
{ id: "x", type: "entityList", entity: "x", columns: [] },
|
|
377
|
+
{ id: "cap-list", type: "entityList", entity: "x", columns: [] },
|
|
378
|
+
],
|
|
387
379
|
navs: [
|
|
388
380
|
{
|
|
389
381
|
id: "x",
|
|
@@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event";
|
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
14
|
import { defaultPrimitives, END_LABEL_MIN_ROWS } from "../primitives";
|
|
15
15
|
import { PageSection, Stack } from "../primitives/layout";
|
|
16
|
-
import { fireEvent, render, screen } from "./test-utils";
|
|
16
|
+
import { fireEvent, render, screen, waitFor } from "./test-utils";
|
|
17
17
|
|
|
18
18
|
const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog, Card } =
|
|
19
19
|
defaultPrimitives;
|
|
@@ -858,6 +858,34 @@ describe("DataTable", () => {
|
|
|
858
858
|
expect(screen.queryByTestId("row-r1-action-delete-dialog")).not.toBeNull();
|
|
859
859
|
});
|
|
860
860
|
|
|
861
|
+
// Regression (solon e2e/cost-category.spec.ts, fw): unconditional
|
|
862
|
+
// preventDefault() in onSelect blocked Radix' auto-close, so the
|
|
863
|
+
// dropdown stayed in the DOM after the confirm flow (role="menu" +
|
|
864
|
+
// body.style.pointerEvents="none" overlay lock never released), even
|
|
865
|
+
// though the write itself had already succeeded.
|
|
866
|
+
test("Kebab: nach Confirm+onTrigger ist das Dropdown-Menu geschlossen (kein Overlay-Lock)", async () => {
|
|
867
|
+
const user = userEvent.setup();
|
|
868
|
+
const onTrigger = mock();
|
|
869
|
+
render(
|
|
870
|
+
<DataTable
|
|
871
|
+
columns={cols}
|
|
872
|
+
rows={rows}
|
|
873
|
+
testId="dt"
|
|
874
|
+
rowActions={[
|
|
875
|
+
{ id: "a", label: "Archive", onTrigger: mock() },
|
|
876
|
+
{ id: "b", label: "Duplicate", onTrigger: mock() },
|
|
877
|
+
{ id: "delete", label: "Delete", style: "danger", onTrigger },
|
|
878
|
+
]}
|
|
879
|
+
/>,
|
|
880
|
+
);
|
|
881
|
+
await user.click(screen.getByTestId("row-r1-actions-menu"));
|
|
882
|
+
await user.click(screen.getByTestId("row-r1-action-delete"));
|
|
883
|
+
await user.click(screen.getByTestId("row-r1-action-delete-dialog-confirm"));
|
|
884
|
+
await waitFor(() => expect(onTrigger).toHaveBeenCalledWith(rows[0]));
|
|
885
|
+
await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
|
|
886
|
+
expect(document.body.style.pointerEvents).not.toBe("none");
|
|
887
|
+
});
|
|
888
|
+
|
|
861
889
|
test("confirmLabel separat vom label: Dialog-Button zeigt confirmLabel", async () => {
|
|
862
890
|
const user = userEvent.setup();
|
|
863
891
|
render(
|
|
@@ -53,7 +53,7 @@ export function PlainContentEditor({
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
return (
|
|
56
|
-
<div>
|
|
56
|
+
<div className="rounded-md border border-input bg-transparent">
|
|
57
57
|
<TextareaContentEditor
|
|
58
58
|
id={id}
|
|
59
59
|
value={value}
|
|
@@ -61,7 +61,11 @@ export function PlainContentEditor({
|
|
|
61
61
|
variables={variables}
|
|
62
62
|
readOnly={readOnly}
|
|
63
63
|
/>
|
|
64
|
-
|
|
64
|
+
{variables.length > 0 && (
|
|
65
|
+
<div className="flex flex-wrap gap-1 border-t border-input p-1">
|
|
66
|
+
<VariableChips variables={variables} onInsert={insertAtCaret} disabled={readOnly} />
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
65
69
|
</div>
|
|
66
70
|
);
|
|
67
71
|
}
|
|
@@ -33,9 +33,12 @@ const enPlaceholderLetters = placeholderLetters("en");
|
|
|
33
33
|
describe("parseIso", () => {
|
|
34
34
|
test("valid yyyy-mm-dd → PlainDate (no TZ conversion)", () => {
|
|
35
35
|
const d = parseIso("2026-04-25");
|
|
36
|
-
// Cross-realm safe:
|
|
37
|
-
//
|
|
38
|
-
|
|
36
|
+
// Cross-realm safe: assert against the same Temporal activeTemporal() uses
|
|
37
|
+
// (native when present), not the polyfill import — Bun 1.4+ ships Temporal.
|
|
38
|
+
const ActivePlainDate = (
|
|
39
|
+
(globalThis as unknown as { Temporal?: typeof Temporal }).Temporal ?? Temporal
|
|
40
|
+
).PlainDate;
|
|
41
|
+
expect(d).toBeInstanceOf(ActivePlainDate);
|
|
39
42
|
expect(String(d)).toBe("2026-04-25");
|
|
40
43
|
expect(d?.year).toBe(2026);
|
|
41
44
|
expect(d?.month).toBe(4);
|
|
@@ -10,6 +10,8 @@ import { Temporal } from "temporal-polyfill";
|
|
|
10
10
|
// Prefer the native Temporal (Chromium 144+/Firefox 139+) over the bundled
|
|
11
11
|
// polyfill so `instanceof` checks match values crossing package boundaries;
|
|
12
12
|
// falls back to the polyfill where native support is absent.
|
|
13
|
+
// Callers must not feed parseIso()/makePlainDate results into polyfill-only
|
|
14
|
+
// Statics (compare/equals/since) — mixed Temporal brands throw TypeError.
|
|
13
15
|
function activeTemporal(): typeof Temporal {
|
|
14
16
|
return (globalThis as unknown as { Temporal?: typeof Temporal }).Temporal ?? Temporal;
|
|
15
17
|
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -1003,10 +1003,10 @@ function RowActionButton({
|
|
|
1003
1003
|
);
|
|
1004
1004
|
}
|
|
1005
1005
|
|
|
1006
|
-
// Kebab
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1006
|
+
// Kebab dropdown for >2 actions, one inline confirm dialog per item like
|
|
1007
|
+
// the inline-button path. Menu is controlled so it closes explicitly on
|
|
1008
|
+
// select, instead of relying on Radix's auto-close, which preventDefault
|
|
1009
|
+
// below blocks.
|
|
1010
1010
|
function RowActionsKebab({
|
|
1011
1011
|
row,
|
|
1012
1012
|
actions,
|
|
@@ -1016,10 +1016,11 @@ function RowActionsKebab({
|
|
|
1016
1016
|
}): ReactNode {
|
|
1017
1017
|
const { triggerNow } = useRowActionTrigger(row);
|
|
1018
1018
|
const [pendingConfirm, setPendingConfirm] = useState<DataTableRowAction | null>(null);
|
|
1019
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
1019
1020
|
|
|
1020
1021
|
return (
|
|
1021
1022
|
<>
|
|
1022
|
-
<DropdownMenu>
|
|
1023
|
+
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
|
1023
1024
|
<DropdownMenuTrigger asChild>
|
|
1024
1025
|
<button
|
|
1025
1026
|
type="button"
|
|
@@ -1041,6 +1042,7 @@ function RowActionsKebab({
|
|
|
1041
1042
|
data-testid={`row-${row.id}-action-${action.id}`}
|
|
1042
1043
|
onSelect={(e) => {
|
|
1043
1044
|
e.preventDefault();
|
|
1045
|
+
setMenuOpen(false);
|
|
1044
1046
|
if (needsConfirm(action)) {
|
|
1045
1047
|
setPendingConfirm(action);
|
|
1046
1048
|
} else {
|
|
@@ -73,7 +73,7 @@ function renderWithLive(ui: ReactNode, dispatcher: Dispatcher, liveEvents: LiveE
|
|
|
73
73
|
type Row = { readonly id: string; readonly subject: string };
|
|
74
74
|
type Page = { readonly rows: readonly Row[]; readonly nextCursor: string | null };
|
|
75
75
|
|
|
76
|
-
function list(query: string) {
|
|
76
|
+
function list(query: string, live = false) {
|
|
77
77
|
return (
|
|
78
78
|
<InfinityList<Page, Row>
|
|
79
79
|
query={query}
|
|
@@ -82,6 +82,7 @@ function list(query: string) {
|
|
|
82
82
|
rowId={(row) => row.id}
|
|
83
83
|
renderRow={(row) => <span>{row.subject}</span>}
|
|
84
84
|
testId="inbox"
|
|
85
|
+
live={live}
|
|
85
86
|
/>
|
|
86
87
|
);
|
|
87
88
|
}
|
|
@@ -255,14 +256,18 @@ describe("InfinityList", () => {
|
|
|
255
256
|
isSuccess: true,
|
|
256
257
|
data: { rows: [{ id: "m1", subject: "Bo-Treffer" }], nextCursor: null },
|
|
257
258
|
});
|
|
258
|
-
await
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
await waitFor(() => {
|
|
260
|
+
expect(screen.queryByText("Bo-Treffer")).toBeNull();
|
|
261
|
+
expect(screen.getByText("Bob-Treffer")).toBeTruthy();
|
|
262
|
+
});
|
|
261
263
|
});
|
|
262
264
|
|
|
263
265
|
// fw#1827: InfinityList used dispatcher.query directly and never subscribed
|
|
264
266
|
// to live events, so a solon inbox stayed stale until the user reloaded.
|
|
265
267
|
describe("Live-Mode", () => {
|
|
268
|
+
// fw#1829 debounces live refresh by 250ms; CI needs headroom beyond default 1s.
|
|
269
|
+
const liveWait = { timeout: 3000 } as const;
|
|
270
|
+
|
|
266
271
|
test("SSE-Event mergt nur die erste Seite, bereits geladene Folgeseiten bleiben erhalten", async () => {
|
|
267
272
|
const calls: Array<Readonly<Record<string, unknown>>> = [];
|
|
268
273
|
const dispatcher = createMockDispatcher({
|
|
@@ -304,6 +309,7 @@ describe("InfinityList", () => {
|
|
|
304
309
|
rowId={(row) => row.id}
|
|
305
310
|
renderRow={(row) => <span>{row.subject}</span>}
|
|
306
311
|
testId="inbox"
|
|
312
|
+
live={true}
|
|
307
313
|
/>,
|
|
308
314
|
dispatcher,
|
|
309
315
|
fake.subscriber,
|
|
@@ -324,7 +330,7 @@ describe("InfinityList", () => {
|
|
|
324
330
|
});
|
|
325
331
|
});
|
|
326
332
|
|
|
327
|
-
await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy());
|
|
333
|
+
await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy(), liveWait);
|
|
328
334
|
expect(screen.getByText("Alt-1")).toBeTruthy();
|
|
329
335
|
expect(screen.getByText("Alt-2")).toBeTruthy();
|
|
330
336
|
expect(screen.getAllByText("Alt-1").length).toBe(1);
|
|
@@ -362,7 +368,7 @@ describe("InfinityList", () => {
|
|
|
362
368
|
});
|
|
363
369
|
const fake = makeFakeLiveEvents();
|
|
364
370
|
|
|
365
|
-
renderWithLive(list("inbox:query:message:list"), dispatcher, fake.subscriber);
|
|
371
|
+
renderWithLive(list("inbox:query:message:list", true), dispatcher, fake.subscriber);
|
|
366
372
|
|
|
367
373
|
await waitFor(() => expect(screen.getByText("Erste")).toBeTruthy());
|
|
368
374
|
expect(screen.getByText("Zweite")).toBeTruthy();
|
|
@@ -377,7 +383,7 @@ describe("InfinityList", () => {
|
|
|
377
383
|
});
|
|
378
384
|
});
|
|
379
385
|
|
|
380
|
-
await waitFor(() => expect(screen.queryByText("Erste")).toBeNull());
|
|
386
|
+
await waitFor(() => expect(screen.queryByText("Erste")).toBeNull(), liveWait);
|
|
381
387
|
expect(screen.getByText("Zweite")).toBeTruthy();
|
|
382
388
|
});
|
|
383
389
|
|
|
@@ -421,6 +427,7 @@ describe("InfinityList", () => {
|
|
|
421
427
|
rowId={(row) => row.id}
|
|
422
428
|
renderRow={(row) => <span>{row.subject}</span>}
|
|
423
429
|
testId="inbox"
|
|
430
|
+
live={true}
|
|
424
431
|
/>,
|
|
425
432
|
dispatcher,
|
|
426
433
|
fake.subscriber,
|
|
@@ -441,7 +448,7 @@ describe("InfinityList", () => {
|
|
|
441
448
|
});
|
|
442
449
|
});
|
|
443
450
|
|
|
444
|
-
await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy());
|
|
451
|
+
await waitFor(() => expect(screen.getByText("Neu")).toBeTruthy(), liveWait);
|
|
445
452
|
expect(screen.queryByText("Erste")).toBeNull();
|
|
446
453
|
expect(screen.getByText("Zweite")).toBeTruthy();
|
|
447
454
|
});
|
|
@@ -508,6 +515,7 @@ describe("InfinityList", () => {
|
|
|
508
515
|
rowId={(row) => row.id}
|
|
509
516
|
renderRow={(row) => <span>{row.subject}</span>}
|
|
510
517
|
testId="inbox"
|
|
518
|
+
live={true}
|
|
511
519
|
/>,
|
|
512
520
|
dispatcher,
|
|
513
521
|
fake.subscriber,
|
|
@@ -540,7 +548,7 @@ describe("InfinityList", () => {
|
|
|
540
548
|
});
|
|
541
549
|
const fake = makeFakeLiveEvents();
|
|
542
550
|
|
|
543
|
-
renderWithLive(list("inbox:query:message:list"), dispatcher, fake.subscriber);
|
|
551
|
+
renderWithLive(list("inbox:query:message:list", true), dispatcher, fake.subscriber);
|
|
544
552
|
|
|
545
553
|
await waitFor(() => expect(resolvers.length).toBe(1));
|
|
546
554
|
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -139,8 +139,10 @@ export function Drawer({
|
|
|
139
139
|
const signedDelta = side === "right" ? -deltaX : deltaX;
|
|
140
140
|
setWidth(clamp(dragRef.current.startWidth + signedDelta, minWidthPx, effectiveMaxWidthPx()));
|
|
141
141
|
};
|
|
142
|
-
const
|
|
143
|
-
event.currentTarget.
|
|
142
|
+
const endHandlePointerDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
143
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
144
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
145
|
+
}
|
|
144
146
|
dragRef.current = null;
|
|
145
147
|
document.body.style.removeProperty("user-select");
|
|
146
148
|
};
|
|
@@ -210,7 +212,9 @@ export function Drawer({
|
|
|
210
212
|
tabIndex={0}
|
|
211
213
|
onPointerDown={onHandlePointerDown}
|
|
212
214
|
onPointerMove={onHandlePointerMove}
|
|
213
|
-
onPointerUp={
|
|
215
|
+
onPointerUp={endHandlePointerDrag}
|
|
216
|
+
onPointerCancel={endHandlePointerDrag}
|
|
217
|
+
onLostPointerCapture={endHandlePointerDrag}
|
|
214
218
|
onKeyDown={onHandleKeyDown}
|
|
215
219
|
className={cn(
|
|
216
220
|
"absolute inset-y-0 z-10 w-1 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-border",
|
|
@@ -28,7 +28,7 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
|
|
|
28
28
|
* (`<feature>:query:<entity>:<verb>`) and refetch the first page on
|
|
29
29
|
* any create/update/delete/restore event, merging it into the
|
|
30
30
|
* already-accumulated rows instead of collapsing them — see
|
|
31
|
-
* `useQuery`'s `live` option for the same convention. Default
|
|
31
|
+
* `useQuery`'s `live` option for the same convention. Default false. */
|
|
32
32
|
readonly live?: boolean;
|
|
33
33
|
};
|
|
34
34
|
|
|
@@ -53,7 +53,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
53
53
|
emptyState,
|
|
54
54
|
className,
|
|
55
55
|
testId,
|
|
56
|
-
live =
|
|
56
|
+
live = false,
|
|
57
57
|
}: InfinityListProps<TData, TRow>): ReactNode {
|
|
58
58
|
const dispatcher = useDispatcher();
|
|
59
59
|
const t = useTranslation();
|
|
@@ -183,15 +183,51 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
183
183
|
});
|
|
184
184
|
}, [dispatcher, query, pageSize, payloadKey]);
|
|
185
185
|
|
|
186
|
+
// Trailing debounce + in-flight coalesce: bulk SSE storms must not fan out
|
|
187
|
+
// into one query per event (fw#1829). A refresh already running schedules
|
|
188
|
+
// exactly one trailing pass after it settles.
|
|
189
|
+
const liveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
190
|
+
const liveInFlightRef = useRef(false);
|
|
191
|
+
const liveTrailingRef = useRef(false);
|
|
192
|
+
|
|
186
193
|
useEffect(() => {
|
|
187
194
|
// skip: live mode off, no SSE subscription needed
|
|
188
195
|
if (!live) return;
|
|
189
196
|
const entity = entityFromQueryType(query);
|
|
190
197
|
// skip: query type has no mapped entity, nothing to subscribe to
|
|
191
198
|
if (entity === undefined) return;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
199
|
+
|
|
200
|
+
const runRefresh = (): void => {
|
|
201
|
+
liveInFlightRef.current = true;
|
|
202
|
+
void refreshFirstPage().finally(() => {
|
|
203
|
+
liveInFlightRef.current = false;
|
|
204
|
+
if (liveTrailingRef.current) {
|
|
205
|
+
liveTrailingRef.current = false;
|
|
206
|
+
runRefresh();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const scheduleRefresh = (): void => {
|
|
212
|
+
if (liveTimerRef.current !== null) clearTimeout(liveTimerRef.current);
|
|
213
|
+
liveTimerRef.current = setTimeout(() => {
|
|
214
|
+
liveTimerRef.current = null;
|
|
215
|
+
if (liveInFlightRef.current) {
|
|
216
|
+
liveTrailingRef.current = true;
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
runRefresh();
|
|
220
|
+
}, 250);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const unsubscribe = subscribeLive(entity, scheduleRefresh);
|
|
224
|
+
return () => {
|
|
225
|
+
unsubscribe();
|
|
226
|
+
if (liveTimerRef.current !== null) {
|
|
227
|
+
clearTimeout(liveTimerRef.current);
|
|
228
|
+
liveTimerRef.current = null;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
195
231
|
}, [live, query, refreshFirstPage, subscribeLive]);
|
|
196
232
|
|
|
197
233
|
useEffect(() => {
|