@cosmicdrift/kumiko-renderer-web 0.221.0 → 0.223.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.221.0",
3
+ "version": "0.223.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.221.0",
20
- "@cosmicdrift/kumiko-headless": "0.221.0",
21
- "@cosmicdrift/kumiko-renderer": "0.221.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.223.0",
20
+ "@cosmicdrift/kumiko-headless": "0.223.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.223.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.221.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.223.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: [{ id: "x", type: "entityList", entity: "x", columns: [] }],
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
- <VariableChips variables={variables} onInsert={insertAtCaret} disabled={readOnly} />
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: bun and happy-dom can expose different Temporal
37
- // copies, so toBeInstanceOf fails depending on file order. The ISO
38
- // string proves both the type and the no-TZ-conversion property.
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);
@@ -0,0 +1,64 @@
1
+ // DefaultGrid's `maxRows` clips the grid to roughly N rows once the option
2
+ // count (given `columns`) actually exceeds that many rows — style-only until
3
+ // then, no extra wrapper element. Height derives from the
4
+ // `--kumiko-grid-row-h` CSS var (same var `grid-auto-rows`'s minmax floor
5
+ // uses), never a hardcoded pixel/rem constant, so a theme redefining the var
6
+ // scales both together. `grid-auto-rows` is a minmax MIN, not a fixed
7
+ // height, so a wrapped label grows its row instead of being clipped.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { render, screen } from "@testing-library/react";
11
+ import { defaultPrimitives } from "../index";
12
+
13
+ const { Grid } = defaultPrimitives;
14
+
15
+ describe("DefaultGrid — maxRows", () => {
16
+ test("fewer options than maxRows rows can hold → no scroll container", () => {
17
+ render(
18
+ <Grid columns={2} maxRows={3} testId="grid">
19
+ <div>a</div>
20
+ <div>b</div>
21
+ </Grid>,
22
+ );
23
+ const el = screen.getByTestId("grid");
24
+ expect(el.style.overflowY).toBe("");
25
+ expect(el.style.maxHeight).toBe("");
26
+ });
27
+
28
+ test("more options than maxRows rows can hold → scroll container with height derived from maxRows", () => {
29
+ render(
30
+ <Grid columns={2} maxRows={2} testId="grid">
31
+ <div>a</div>
32
+ <div>b</div>
33
+ <div>c</div>
34
+ <div>d</div>
35
+ <div>e</div>
36
+ </Grid>,
37
+ );
38
+ const el = screen.getByTestId("grid");
39
+ expect(el.style.overflowY).toBe("auto");
40
+ expect(el.style.maxHeight).toContain("var(--kumiko-grid-row-h");
41
+ expect(el.style.maxHeight).toContain("2 *");
42
+ // minmax(..., auto), not a fixed height — a row taller than the CSS var
43
+ // (a wrapped label) grows instead of getting clipped mid-row.
44
+ const gridAutoRows = el.style.getPropertyValue("grid-auto-rows");
45
+ expect(gridAutoRows).toContain("var(--kumiko-grid-row-h");
46
+ expect(gridAutoRows).toContain("minmax(");
47
+ expect(gridAutoRows).toContain("auto");
48
+ });
49
+
50
+ test("no maxRows at all → never a scroll container regardless of option count", () => {
51
+ render(
52
+ <Grid columns={2} testId="grid">
53
+ <div>a</div>
54
+ <div>b</div>
55
+ <div>c</div>
56
+ <div>d</div>
57
+ <div>e</div>
58
+ </Grid>,
59
+ );
60
+ const el = screen.getByTestId("grid");
61
+ expect(el.style.overflowY).toBe("");
62
+ expect(el.style.maxHeight).toBe("");
63
+ });
64
+ });
@@ -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
  }
@@ -70,6 +70,7 @@ import {
70
70
  } from "lucide-react";
71
71
  import {
72
72
  type ChangeEvent,
73
+ Children,
73
74
  type CSSProperties,
74
75
  createContext,
75
76
  type MouseEvent,
@@ -1003,10 +1004,10 @@ function RowActionButton({
1003
1004
  );
1004
1005
  }
1005
1006
 
1006
- // Kebab-Dropdown für >2 actions. Confirm-Dialog ist hier inline pro
1007
- // Item analog zum Inline-Button-Pfad ein gemeinsamer State-Holder
1008
- // per Action damit das Dropdown nach dem Click zumacht und der Dialog
1009
- // danach öffnet (Radix-Dropdown-Item swallowt den Click sonst).
1007
+ // Kebab dropdown for >2 actions, one inline confirm dialog per item like
1008
+ // the inline-button path. Menu is controlled so it closes explicitly on
1009
+ // select, instead of relying on Radix's auto-close, which preventDefault
1010
+ // below blocks.
1010
1011
  function RowActionsKebab({
1011
1012
  row,
1012
1013
  actions,
@@ -1016,10 +1017,11 @@ function RowActionsKebab({
1016
1017
  }): ReactNode {
1017
1018
  const { triggerNow } = useRowActionTrigger(row);
1018
1019
  const [pendingConfirm, setPendingConfirm] = useState<DataTableRowAction | null>(null);
1020
+ const [menuOpen, setMenuOpen] = useState(false);
1019
1021
 
1020
1022
  return (
1021
1023
  <>
1022
- <DropdownMenu>
1024
+ <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
1023
1025
  <DropdownMenuTrigger asChild>
1024
1026
  <button
1025
1027
  type="button"
@@ -1041,6 +1043,7 @@ function RowActionsKebab({
1041
1043
  data-testid={`row-${row.id}-action-${action.id}`}
1042
1044
  onSelect={(e) => {
1043
1045
  e.preventDefault();
1046
+ setMenuOpen(false);
1044
1047
  if (needsConfirm(action)) {
1045
1048
  setPendingConfirm(action);
1046
1049
  } else {
@@ -1846,18 +1849,38 @@ function DefaultSection({
1846
1849
  );
1847
1850
  }
1848
1851
 
1849
- function DefaultGrid({ columns, children, testId }: GridProps): ReactNode {
1852
+ function DefaultGrid({ columns, children, testId, maxRows }: GridProps): ReactNode {
1850
1853
  // Responsive: Mobile (< sm = 640px) bleibt 1-spaltig, ab sm: greift
1851
1854
  // die Author-deklarierte Spaltenzahl. Inline-style schreibt
1852
1855
  // CSS-Variable; Tailwind-Klasse liest die Variable mit
1853
1856
  // `grid-template-columns: var(--grid-cols)`. Saubere Lösung weil
1854
1857
  // Tailwind JIT keinen dynamischen `grid-cols-${N}` auflösen kann.
1858
+ const rowCount = Math.ceil(Children.count(children) / columns);
1859
+ const clipped = maxRows !== undefined && rowCount > maxRows;
1860
+ // gridAutoRows is a minmax MIN, not a fixed height, so wrapped labels grow
1861
+ // their row instead of being clipped mid-row. maxHeight is therefore only
1862
+ // an approximation of "maxRows rows tall" — a row taller than the minimum
1863
+ // means fewer than maxRows rows end up visible before scrolling kicks in.
1864
+ // Themes may override --kumiko-grid-row-h (default 2.5rem, set in
1865
+ // styles.css) to change that minimum.
1866
+ // Mobile note: the grid collapses to 1 column below `sm` (see className),
1867
+ // but rowCount above is always computed against the desktop `columns` —
1868
+ // inline styles can't express a media query, so the mobile row count is
1869
+ // an under-count. Known, accepted.
1870
+ const style: CSSProperties = {
1871
+ "--grid-cols": `repeat(${columns}, minmax(0, 1fr))`,
1872
+ ...(clipped && {
1873
+ gridAutoRows: "minmax(var(--kumiko-grid-row-h, 2.5rem), auto)",
1874
+ maxHeight: `calc(${maxRows} * var(--kumiko-grid-row-h, 2.5rem) + ${maxRows - 1} * 1rem)`,
1875
+ overflowY: "auto",
1876
+ }),
1877
+ // workaround: duplicate @types/react instances break direct CSSProperties cast
1878
+ } as unknown as CSSProperties;
1855
1879
  return (
1856
1880
  <div
1857
1881
  data-testid={testId}
1858
1882
  className="grid gap-4 grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]"
1859
- // workaround: duplicate @types/react instances break direct CSSProperties cast
1860
- style={{ "--grid-cols": `repeat(${columns}, minmax(0, 1fr))` } as unknown as CSSProperties}
1883
+ style={style}
1861
1884
  >
1862
1885
  {children}
1863
1886
  </div>
package/src/styles.css CHANGED
@@ -99,6 +99,7 @@
99
99
  --card-padding: 1.5rem;
100
100
  --card-radius: var(--radius-xl);
101
101
  --card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
102
+ --kumiko-grid-row-h: 2.5rem;
102
103
  }
103
104
 
104
105
  /* Light-Mode-Overrides — aktiv wenn `<html>` KEINE `.dark` Klasse
@@ -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 new Promise((resolve) => setTimeout(resolve, 10));
259
- expect(screen.queryByText("Bo-Treffer")).toBeNull();
260
- expect(screen.getByText("Bob-Treffer")).toBeTruthy();
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
 
@@ -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 onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
143
- event.currentTarget.releasePointerCapture(event.pointerId);
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={onHandlePointerUp}
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 true. */
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 = true,
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
- return subscribeLive(entity, () => {
193
- void refreshFirstPage();
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(() => {