@cosmicdrift/kumiko-renderer-web 0.174.0 → 0.176.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.174.0",
3
+ "version": "0.176.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.174.0",
20
- "@cosmicdrift/kumiko-headless": "0.174.0",
21
- "@cosmicdrift/kumiko-renderer": "0.174.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.176.0",
20
+ "@cosmicdrift/kumiko-headless": "0.176.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.176.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",
@@ -470,16 +470,21 @@ describe("DataTable", () => {
470
470
  });
471
471
  });
472
472
 
473
- // Infinite-Scroll Sentinel: rendert sentinel-div, zeigt Spinner wenn
474
- // loadingMore, den i18n End-of-list-Marker (kumiko.list.end-of-list)
475
- // wenn !hasMore und genug Zeilen geladen sind (kurze Listen brauchen
476
- // keinen Marker sie zeigen ihr Ende selbst). IntersectionObserver
477
- // selbst ist in jsdom unmocked wir testen nur die Marker, der
478
- // Observer-Fire-Pfad ist im KumikoScreen.EntityListBody.
473
+ // Infinite-scroll sentinel: renders a sentinel div, shows a spinner when
474
+ // loadingMore, and the i18n end-of-list marker (kumiko.list.end-of-list)
475
+ // when !hasMore and enough rows have loaded (short lists don't need a
476
+ // markerthey show their end on their own). IntersectionObserver itself
477
+ // is unmocked in jsdom — we only test the marker; the observer-fire path
478
+ // lives in KumikoScreen.EntityListBody.
479
479
  describe("InfiniteSentinel", () => {
480
480
  const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
481
481
  const oneRow = [{ id: "r1", values: { name: "A" } }];
482
- const manyRows = Array.from({ length: 25 }, (_, i) => ({
482
+ // 60 rows above END_LABEL_MIN_ROWS (50, the framework default pageSize)
483
+ // so hasMore=false genuinely means "reached the end of a full default
484
+ // page". 25 used to pass here too, back when the threshold was a
485
+ // mismatched 20 (fw#1713) — that under-a-page case is now covered
486
+ // separately below by the exact-boundary tests.
487
+ const manyRows = Array.from({ length: 60 }, (_, i) => ({
483
488
  id: `r${i}`,
484
489
  values: { name: `A${i}` },
485
490
  }));
@@ -30,6 +30,7 @@ import {
30
30
  SidebarProvider,
31
31
  SidebarRail,
32
32
  } from "../ui/sidebar";
33
+ import { fillClasses } from "./fill-classes";
33
34
  import { NavTree } from "./nav-tree";
34
35
  import { ShellHeader } from "./shell-header";
35
36
 
@@ -83,8 +84,9 @@ export function DefaultAppShell({
83
84
  fill,
84
85
  children,
85
86
  }: DefaultAppShellProps): ReactNode {
87
+ const fillCls = fillClasses(fill);
86
88
  return (
87
- <SidebarProvider {...(fill === true && { className: "h-svh" })}>
89
+ <SidebarProvider {...fillCls.provider}>
88
90
  {/* sidebar-07-Muster: Standard-Variante (border-r, flush content) +
89
91
  collapsible="icon" — der Rail klappt auf Icon-Breite zu (Trigger/Rail). */}
90
92
  <Sidebar collapsible="icon">
@@ -109,13 +111,13 @@ export function DefaultAppShell({
109
111
  )}
110
112
  <SidebarRail />
111
113
  </Sidebar>
112
- <SidebarInset className={fill === true ? "min-h-0" : undefined}>
114
+ <SidebarInset className={fillCls.inset}>
113
115
  <ShellHeader
114
116
  schema={schema}
115
117
  {...(user !== undefined && { user })}
116
118
  {...(headerActions !== undefined && { headerActions })}
117
119
  />
118
- <main className={fill === true ? "min-h-0 flex-1 overflow-auto" : "flex-1 overflow-auto"}>
120
+ <main className={fillCls.main}>
119
121
  <UserRolesProvider roles={user?.roles}>{children}</UserRolesProvider>
120
122
  </main>
121
123
  </SidebarInset>
@@ -0,0 +1,8 @@
1
+ // Shared `fill`-prop wiring for the sidebar-based app shells
2
+ // (DefaultAppShell, WorkspaceShell) — both wrap the same vendored
3
+ // shadcn sidebar and need identical viewport-fit classes.
4
+ export const fillClasses = (fill?: boolean) => ({
5
+ provider: fill === true ? { className: "h-svh" } : {},
6
+ inset: fill === true ? "min-h-0" : undefined,
7
+ main: fill === true ? "min-h-0 flex-1 overflow-auto" : "flex-1 overflow-auto",
8
+ });
@@ -44,6 +44,7 @@ import {
44
44
  SidebarRail,
45
45
  } from "../ui/sidebar";
46
46
  import { EditorPanel } from "./editor-panel";
47
+ import { fillClasses } from "./fill-classes";
47
48
  import { lastSegment, NavTree } from "./nav-tree";
48
49
  import { ShellHeader } from "./shell-header";
49
50
  import { parseTargetFromSearchParams } from "./target-url";
@@ -218,13 +219,14 @@ export function WorkspaceShell({
218
219
  />
219
220
  );
220
221
 
221
- // Modern shell (wie DefaultAppShell): collapsible Icon-Rail-Sidebar trägt
222
- // Brand + Workspace-Switcher + Nav + Footer; der SidebarInset rendert die
223
- // geteilte ShellHeader (Panel-Toggle + Breadcrumb + Actions) über dem
224
- // Content. topbarActions landen rechts in der Header-Zeile statt in einer
225
- // separaten Topbareine Kopfzeile statt zwei.
222
+ // Modern shell (like DefaultAppShell): collapsible icon-rail sidebar
223
+ // carries brand + workspace switcher + nav + footer; the SidebarInset
224
+ // renders the shared ShellHeader (panel toggle + breadcrumb + actions)
225
+ // above the content. topbarActions render right inside the header row
226
+ // instead of a separate topbar one header line, not two.
227
+ const fillCls = fillClasses(fill);
226
228
  return (
227
- <SidebarProvider {...(fill === true && { className: "h-svh" })}>
229
+ <SidebarProvider {...fillCls.provider}>
228
230
  <Sidebar collapsible="icon">
229
231
  <SidebarHeader data-kumiko-layout="sidebar-header">
230
232
  {brand}
@@ -236,13 +238,13 @@ export function WorkspaceShell({
236
238
  )}
237
239
  <SidebarRail />
238
240
  </Sidebar>
239
- <SidebarInset className={fill === true ? "min-h-0" : undefined}>
241
+ <SidebarInset className={fillCls.inset}>
240
242
  <ShellHeader
241
243
  schema={app}
242
244
  {...(user !== undefined && { user })}
243
245
  {...(topbarActions !== undefined && { headerActions: topbarActions })}
244
246
  />
245
- <main className={fill === true ? "min-h-0 flex-1 overflow-auto" : "flex-1 overflow-auto"}>
247
+ <main className={fillCls.main}>
246
248
  {activeTarget !== undefined ? (
247
249
  <EditorPanel resolvers={resolvers} />
248
250
  ) : (
@@ -7,6 +7,13 @@
7
7
 
8
8
  import { Temporal } from "temporal-polyfill";
9
9
 
10
+ // Prefer the native Temporal (Chromium 144+/Firefox 139+) over the bundled
11
+ // polyfill so `instanceof` checks match values crossing package boundaries;
12
+ // falls back to the polyfill where native support is absent.
13
+ function activeTemporal(): typeof Temporal {
14
+ return (globalThis as unknown as { Temporal?: typeof Temporal }).Temporal ?? Temporal;
15
+ }
16
+
10
17
  export function guessLocale(): string {
11
18
  if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
12
19
  return "en-US";
@@ -17,7 +24,7 @@ export function guessLocale(): string {
17
24
  // a different date.
18
25
  function makePlainDate(y: number, m: number, d: number): Temporal.PlainDate | undefined {
19
26
  try {
20
- return Temporal.PlainDate.from({ year: y, month: m, day: d }, { overflow: "reject" });
27
+ return activeTemporal().PlainDate.from({ year: y, month: m, day: d }, { overflow: "reject" });
21
28
  } catch {
22
29
  return undefined;
23
30
  }
@@ -67,9 +74,9 @@ type DateSlot = "y" | "m" | "d";
67
74
  // "UTC" keeps the reference from shifting to the 1st depending on the
68
75
  // browser's TZ.
69
76
  function localeDateOrder(locale: string): readonly DateSlot[] {
70
- const refEpochMillis = Temporal.PlainDate.from({ year: 2026, month: 1, day: 2 }).toZonedDateTime(
71
- "UTC",
72
- ).epochMilliseconds;
77
+ const refEpochMillis = activeTemporal()
78
+ .PlainDate.from({ year: 2026, month: 1, day: 2 })
79
+ .toZonedDateTime("UTC").epochMilliseconds;
73
80
  const order: DateSlot[] = [];
74
81
  for (const part of new Intl.DateTimeFormat(locale, { timeZone: "UTC" }).formatToParts(
75
82
  refEpochMillis,
@@ -288,12 +288,16 @@ const FIELD_ICONS: Readonly<Record<string, typeof Mail>> = {
288
288
  "map-pin": MapPin,
289
289
  };
290
290
 
291
+ function fieldIconFor(icon: string | undefined): (typeof FIELD_ICONS)[string] | undefined {
292
+ return icon !== undefined ? FIELD_ICONS[icon] : undefined;
293
+ }
294
+
291
295
  // Wraps a text/number input with a left-positioned prefix icon when
292
296
  // `icon` carries a known FIELD_ICONS key. `pl-8` overrides (via
293
297
  // tailwind-merge) only the left padding of the vendored ui/input.tsx —
294
298
  // right padding and other defaults stay untouched.
295
299
  function withFieldIcon(icon: string | undefined, input: ReactNode): ReactNode {
296
- const Icon = icon !== undefined ? FIELD_ICONS[icon] : undefined;
300
+ const Icon = fieldIconFor(icon);
297
301
  if (Icon === undefined) return input;
298
302
  return (
299
303
  <div className="relative">
@@ -329,7 +333,7 @@ function DefaultInput(props: InputProps): ReactNode {
329
333
  onChange={(e: ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
330
334
  {...(props.placeholder !== undefined && { placeholder: props.placeholder })}
331
335
  {...(props.autoComplete !== undefined && { autoComplete: props.autoComplete })}
332
- className={cn(props.icon !== undefined && FIELD_ICONS[props.icon] ? "pl-8" : undefined)}
336
+ className={cn(fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined)}
333
337
  />,
334
338
  );
335
339
  case "email":
@@ -369,7 +373,7 @@ function DefaultInput(props: InputProps): ReactNode {
369
373
  }}
370
374
  className={cn(
371
375
  "text-right tabular-nums",
372
- props.icon !== undefined && FIELD_ICONS[props.icon] ? "pl-8" : undefined,
376
+ fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined,
373
377
  )}
374
378
  />,
375
379
  );
@@ -1012,15 +1016,23 @@ function RowActionsKebab({
1012
1016
  );
1013
1017
  }
1014
1018
 
1015
- // InfiniteSentinel — leeres div am Ende der Tabelle, das via
1016
- // IntersectionObserver erkennt wann der User in die Nähe des Listen-
1017
- // Endes scrollt. onReachEnd feuert genau einmal pro "wird sichtbar"-
1018
- // Übergang; der Caller debounced via loadingMore (während eine Page
1019
- // lädt, ignorieren wir weitere Sichtbar-Events). Kein observer in
1020
- // Server-Side-Render, kein observer wenn hasMore=false — dann zeigt
1021
- // der Sentinel nur den End-of-list-Hinweis.
1022
- // ponytail: Short lists do not need an end marker.
1023
- export const END_LABEL_MIN_ROWS = 20;
1019
+ // InfiniteSentinel — empty div at the end of the table that uses
1020
+ // IntersectionObserver to detect when the user scrolls near the list end.
1021
+ // onReachEnd fires exactly once per "becomes visible" transition; the
1022
+ // caller debounces via loadingMore (further visibility events are ignored
1023
+ // while a page is loading). No observer during server-side render, no
1024
+ // observer when hasMore=false — then the sentinel only shows the
1025
+ // end-of-list hint via t("kumiko.list.end-of-list"), which requires a
1026
+ // LocaleProvider in the tree (same requirement as Field/useRowActionTrigger).
1027
+ // Without one, useTranslation() throws; with one that's missing the framework
1028
+ // defaults (fallbackBundles not including kumikoDefaultTranslations), the
1029
+ // user sees the raw key instead of translated text.
1030
+ // ponytail: threshold mirrors the framework default pageSize (kumiko-screen.tsx
1031
+ // `screen.pageSize ?? 50`) rather than the real per-screen pageSize, which isn't
1032
+ // threaded down to this component. A screen with a custom pageSize can still
1033
+ // under- or over-show the marker; thread pageSize through KumikoScreen →
1034
+ // RenderList → DataTable → InfiniteSentinel if that's needed (fw#1713).
1035
+ export const END_LABEL_MIN_ROWS = 50;
1024
1036
 
1025
1037
  function InfiniteSentinel({
1026
1038
  onReachEnd,
@@ -129,4 +129,58 @@ describe("InfinityList", () => {
129
129
  );
130
130
  await waitFor(() => expect(screen.getByText("Keine Nachrichten")).toBeTruthy());
131
131
  });
132
+
133
+ // fw#1705: a fast payload change (e.g. two keystrokes in a search field)
134
+ // fires a second request before the first resolves. Without sequencing,
135
+ // a slow first response landing after a faster second one clobbers it —
136
+ // the displayed rows end up out of sync with the current payload.
137
+ test("verwirft eine überholte Response, wenn eine frühere Anfrage später auflöst", async () => {
138
+ const resolvers: Array<(res: unknown) => void> = [];
139
+ const dispatcher = createMockDispatcher({
140
+ query: (() =>
141
+ new Promise((resolve) => {
142
+ resolvers.push(resolve);
143
+ })) as unknown as Dispatcher["query"],
144
+ });
145
+
146
+ function SearchList({ search }: { readonly search: string }) {
147
+ return (
148
+ <InfinityList<Page, Row>
149
+ query="inbox:query:message:list"
150
+ payload={{ search }}
151
+ rows={(data) => data.rows}
152
+ nextCursor={(data) => data.nextCursor}
153
+ rowId={(row) => row.id}
154
+ renderRow={(row) => <span>{row.subject}</span>}
155
+ testId="inbox"
156
+ />
157
+ );
158
+ }
159
+
160
+ const { rerender } = renderWithDispatcher(<SearchList search="Bo" />, dispatcher);
161
+ await waitFor(() => expect(resolvers.length).toBe(1));
162
+
163
+ rerender(
164
+ <DispatcherProvider dispatcher={dispatcher}>
165
+ <SearchList search="Bob" />
166
+ </DispatcherProvider>,
167
+ );
168
+ await waitFor(() => expect(resolvers.length).toBe(2));
169
+
170
+ // "Bob" (second, faster) resolves first.
171
+ resolvers[1]?.({
172
+ isSuccess: true,
173
+ data: { rows: [{ id: "m2", subject: "Bob-Treffer" }], nextCursor: null },
174
+ });
175
+ await waitFor(() => expect(screen.getByText("Bob-Treffer")).toBeTruthy());
176
+
177
+ // "Bo" (first, slower) resolves late — must be discarded, not overwrite the display.
178
+ resolvers[0]?.({
179
+ isSuccess: true,
180
+ data: { rows: [{ id: "m1", subject: "Bo-Treffer" }], nextCursor: null },
181
+ });
182
+ await new Promise((resolve) => setTimeout(resolve, 10));
183
+ expect(screen.queryByText("Bo-Treffer")).toBeNull();
184
+ expect(screen.getByText("Bob-Treffer")).toBeTruthy();
185
+ });
132
186
  });
@@ -56,14 +56,22 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
56
56
  const nextCursorRef = useRef(nextCursor);
57
57
  nextCursorRef.current = nextCursor;
58
58
 
59
+ // Discards a response whose request was superseded by a newer one before
60
+ // it resolved (e.g. two searches fired in quick succession) — without
61
+ // this, a slow earlier response can land after a faster later one and
62
+ // overwrite it, leaving stale rows on screen (fw#1705).
63
+ const requestSeq = useRef(0);
64
+
59
65
  // biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
60
66
  const load = useCallback(
61
67
  async (cursor: string | null): Promise<void> => {
68
+ const mySeq = ++requestSeq.current;
62
69
  const res = await dispatcher.query<TData>(query, {
63
70
  ...payload,
64
71
  limit: pageSize,
65
72
  ...(cursor !== null && { cursor }),
66
73
  });
74
+ if (mySeq !== requestSeq.current) return;
67
75
  if (!res.isSuccess) {
68
76
  setState({ kind: "error", error: res.error });
69
77
  return;
@@ -81,6 +89,9 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
81
89
  useEffect(() => {
82
90
  setState({ kind: "loading" });
83
91
  void load(null);
92
+ return () => {
93
+ requestSeq.current += 1;
94
+ };
84
95
  }, [load]);
85
96
 
86
97
  useEffect(() => {