@cosmicdrift/kumiko-renderer 0.195.0 → 0.196.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",
3
- "version": "0.195.0",
3
+ "version": "0.196.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.195.0",
19
- "@cosmicdrift/kumiko-headless": "0.195.0",
18
+ "@cosmicdrift/kumiko-framework": "0.196.0",
19
+ "@cosmicdrift/kumiko-headless": "0.196.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -0,0 +1,215 @@
1
+ // Bug found while archiving a cost-type in solon (kumiko-framework#113-adjacent):
2
+ // a `writeHandler` row-action on an entityList screen writes successfully
3
+ // (HTTP 200, projection updated) but the list keeps showing the old state,
4
+ // because nothing ever refetches the rows query after the write resolves.
5
+ // Actions with a `redirect`/navigate target hide this — the screen remount
6
+ // reloads everything. This test renders the real list path (KumikoScreen →
7
+ // EntityListScreen → EntityListBody → RenderList) under a stub dispatcher
8
+ // that counts `query()` calls, and proves the rows query refetches after a
9
+ // successful row-action write, but NOT after a failed one (no double-fetch,
10
+ // no silent refetch loop).
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import type {
14
+ EntityDefinition,
15
+ EntityListScreenDefinition,
16
+ } from "@cosmicdrift/kumiko-framework/ui-types";
17
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
18
+ import { act, render, waitFor } from "@testing-library/react";
19
+ import type { ComponentType, ReactNode } from "react";
20
+ import { DispatcherProvider } from "../../context/dispatcher-context";
21
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
22
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
23
+ import {
24
+ type CorePrimitives,
25
+ type DataTableProps,
26
+ type DataTableRowAction,
27
+ PrimitivesProvider,
28
+ } from "../../primitives";
29
+ import type { FeatureSchema } from "../feature-schema";
30
+ import { KumikoScreen } from "../kumiko-screen";
31
+ import { NavProvider } from "../nav";
32
+
33
+ let capturedRowActions: readonly DataTableRowAction[] | undefined;
34
+ const captureDataTable: ComponentType<DataTableProps> = (props) => {
35
+ capturedRowActions = props.rowActions;
36
+ return null;
37
+ };
38
+ const noop = (): ReactNode => null;
39
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
40
+
41
+ const testPrimitives: CorePrimitives = {
42
+ Button: noop,
43
+ Banner: passChildren,
44
+ Field: passChildren,
45
+ Input: noop,
46
+ DataTable: captureDataTable,
47
+ Form: passChildren,
48
+ Section: passChildren,
49
+ Card: passChildren,
50
+ Grid: passChildren,
51
+ GridCell: passChildren,
52
+ Text: passChildren,
53
+ Heading: noop,
54
+ Dialog: noop,
55
+ Modal: noop,
56
+ Lightbox: noop,
57
+ ConfigSourceBadge: noop,
58
+ ConfigCascadeView: noop,
59
+ Link: noop,
60
+ };
61
+
62
+ let queryCallCount = 0;
63
+ let writeIsSuccess = true;
64
+
65
+ function stubDispatcher(): Dispatcher {
66
+ return {
67
+ write: (async () => {
68
+ if (!writeIsSuccess) {
69
+ return {
70
+ isSuccess: false,
71
+ error: {
72
+ code: "unknown",
73
+ httpStatus: 500,
74
+ i18nKey: "kumiko:error:unknown",
75
+ message: "boom",
76
+ },
77
+ };
78
+ }
79
+ return { isSuccess: true, data: {} };
80
+ }) as unknown as Dispatcher["write"],
81
+ query: (async () => {
82
+ queryCallCount += 1;
83
+ return {
84
+ isSuccess: true,
85
+ data: { rows: [{ id: "unit-1", status: "active" }], nextCursor: null, total: 1 },
86
+ };
87
+ }) as unknown as Dispatcher["query"],
88
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
89
+ statusStore: {
90
+ getState: () => "online",
91
+ subscribe: () => () => {},
92
+ } as unknown as Dispatcher["statusStore"],
93
+ async *stream() {},
94
+ pendingWrites: () => [],
95
+ pendingFiles: () => [],
96
+ };
97
+ }
98
+
99
+ function buildSchema(): FeatureSchema {
100
+ const entity: EntityDefinition = {
101
+ fields: {
102
+ status: {
103
+ type: "text",
104
+ maxLength: 50,
105
+ required: false,
106
+ searchable: false,
107
+ sortable: false,
108
+ },
109
+ },
110
+ };
111
+ const listScreen: EntityListScreenDefinition = {
112
+ id: "unit-list",
113
+ type: "entityList",
114
+ entity: "unit",
115
+ columns: ["status"],
116
+ rowActions: [
117
+ {
118
+ id: "archive",
119
+ label: "Archive",
120
+ handler: "units:write:unit:archive",
121
+ },
122
+ ],
123
+ };
124
+ return {
125
+ featureName: "units",
126
+ entities: { unit: entity },
127
+ screens: [listScreen],
128
+ } as FeatureSchema;
129
+ }
130
+
131
+ function renderListScreen(): void {
132
+ render(
133
+ <LocaleProvider
134
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
135
+ fallbackBundles={[kumikoDefaultTranslations]}
136
+ >
137
+ <DispatcherProvider dispatcher={stubDispatcher()}>
138
+ <NavProvider
139
+ value={{
140
+ route: { screenId: "units:unit-list" },
141
+ navigate: () => {},
142
+ replace: () => {},
143
+ hrefFor: () => "",
144
+ searchParams: {},
145
+ setSearchParams: () => {},
146
+ }}
147
+ >
148
+ <PrimitivesProvider value={testPrimitives}>
149
+ <KumikoScreen schema={buildSchema()} qn="units:screen:unit-list" />
150
+ </PrimitivesProvider>
151
+ </NavProvider>
152
+ </DispatcherProvider>
153
+ </LocaleProvider>,
154
+ );
155
+ }
156
+
157
+ function requireArchiveAction(): DataTableRowAction {
158
+ const action = capturedRowActions?.find((a) => a.id === "archive");
159
+ if (!action) throw new Error("expected the 'archive' row action to be captured");
160
+ return action;
161
+ }
162
+
163
+ describe("entityList row-action writeHandler refetches the rows query", () => {
164
+ test("a successful row-action write triggers exactly one refetch", async () => {
165
+ capturedRowActions = undefined;
166
+ queryCallCount = 0;
167
+ writeIsSuccess = true;
168
+
169
+ renderListScreen();
170
+
171
+ await waitFor(() => {
172
+ expect(capturedRowActions).toBeDefined();
173
+ });
174
+
175
+ const countAfterMount = queryCallCount;
176
+ expect(countAfterMount).toBeGreaterThan(0);
177
+
178
+ await act(async () => {
179
+ await requireArchiveAction().onTrigger({ id: "unit-1", values: { id: "unit-1" } });
180
+ });
181
+
182
+ await waitFor(() => {
183
+ expect(queryCallCount).toBe(countAfterMount + 1);
184
+ });
185
+
186
+ // Give any accidental extra refetch a chance to land before asserting
187
+ // there wasn't one (no double-fetch, no refetch loop).
188
+ await new Promise((resolve) => setTimeout(resolve, 20));
189
+ expect(queryCallCount).toBe(countAfterMount + 1);
190
+ });
191
+
192
+ test("a failed row-action write does not refetch the rows query", async () => {
193
+ capturedRowActions = undefined;
194
+ queryCallCount = 0;
195
+ writeIsSuccess = false;
196
+
197
+ renderListScreen();
198
+
199
+ await waitFor(() => {
200
+ expect(capturedRowActions).toBeDefined();
201
+ });
202
+
203
+ const countAfterMount = queryCallCount;
204
+ expect(countAfterMount).toBeGreaterThan(0);
205
+
206
+ await act(async () => {
207
+ await expect(
208
+ requireArchiveAction().onTrigger({ id: "unit-1", values: { id: "unit-1" } }),
209
+ ).rejects.toThrow();
210
+ });
211
+
212
+ await new Promise((resolve) => setTimeout(resolve, 20));
213
+ expect(queryCallCount).toBe(countAfterMount);
214
+ });
215
+ });
@@ -3,7 +3,7 @@ import type {
3
3
  EntityEditScreenDefinition,
4
4
  FieldDefinition,
5
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
- import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import { evalFieldCondition, NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-framework/ui-types";
7
7
  import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
8
8
  import { z } from "zod";
9
9
  import { layoutEditFields } from "./layout-fields";
@@ -16,23 +16,13 @@ function isPresent(value: unknown): boolean {
16
16
  return true;
17
17
  }
18
18
 
19
- // Field types without a bound, editable widget on the auto-wired
20
- // entityEdit path (render-field.tsx renders a read-only banner instead) —
21
- // a presence error on one of them would be unresolvable by the user.
22
- // #1925 gave multiSelect/decimal/bigInt/tz/longText real widgets, so they
23
- // dropped out of this set; files/images stay out of scope (deferred, no
24
- // multi-upload widget yet) alongside jsonb/embedded (structural types with
25
- // no editor at all). A statically-`required: true` field of one of these
26
- // types is caught loudly at boot — validateNoWidgetRequiredField in
27
- // packages/framework/src/engine/boot-validator/screens.ts, which mirrors
28
- // this set (framework can't import renderer, so it can't import this
29
- // constant directly — keep both in sync).
30
- const FIELD_TYPES_WITHOUT_WIDGET: ReadonlySet<FieldDefinition["type"]> = new Set([
31
- "jsonb",
32
- "embedded",
33
- "files",
34
- "images",
35
- ]);
19
+ // A statically-`required: true` field with no bound widget is caught
20
+ // loudly at boot validateNoWidgetRequiredField in
21
+ // packages/framework/src/engine/boot-validator/screens.ts, sharing
22
+ // NO_WIDGET_FIELD_TYPES with the presence check below.
23
+ function isEmbeddedListField(field: FieldDefinition): boolean {
24
+ return field.type === "embedded" && field.multiple === true;
25
+ }
36
26
 
37
27
  // Renders raw to the user without a de+en default in i18n-defaults.ts.
38
28
  export const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
@@ -86,7 +76,10 @@ export function buildFormSchema(
86
76
  const isRequired =
87
77
  spec.required === undefined ? entityRequired : evalFieldCondition(spec.required, record);
88
78
  if (!isRequired) continue;
89
- if (FIELD_TYPES_WITHOUT_WIDGET.has(field.type)) continue;
79
+ // Embedded LIST fields get their own EmbeddedListField grid widget
80
+ // (#1838) — they're fillable, so NO_WIDGET_FIELD_TYPES's "embedded"
81
+ // entry must not exempt them from the presence check below.
82
+ if (NO_WIDGET_FIELD_TYPES.includes(field.type) && !isEmbeddedListField(field)) continue;
90
83
  if (isPresent(record[spec.field])) continue;
91
84
  ctx.addIssue({
92
85
  code: "custom",
@@ -1136,6 +1136,9 @@ function EntityListBody({
1136
1136
  dispatcherErrorText(result.error, effectiveTranslate),
1137
1137
  );
1138
1138
  }
1139
+ // Refetch — without a redirect nothing else remounts the screen,
1140
+ // so the list would otherwise keep showing stale rows.
1141
+ await rowsQuery.refetch();
1139
1142
  },
1140
1143
  isVisible:
1141
1144
  writeActionVisible !== undefined
@@ -1144,7 +1147,7 @@ function EntityListBody({
1144
1147
  };
1145
1148
  })
1146
1149
  .filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
1147
- }, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate]);
1150
+ }, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, rowsQuery.refetch]);
1148
1151
 
1149
1152
  // ToolbarActions: Schema → Resolved-Form (analog rowActions).
1150
1153
  // navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
@@ -1186,11 +1189,13 @@ function EntityListBody({
1186
1189
  dispatcherErrorText(result.error, effectiveTranslate),
1187
1190
  );
1188
1191
  }
1192
+ // Same refetch as rowActions above.
1193
+ await rowsQuery.refetch();
1189
1194
  },
1190
1195
  };
1191
1196
  })
1192
1197
  .filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
1193
- }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
1198
+ }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch]);
1194
1199
 
1195
1200
  if (rowsQuery.loading && rowsQuery.data === null) {
1196
1201
  return (
@@ -1375,6 +1380,8 @@ function ProjectionListBody({
1375
1380
  dispatcherErrorText(result.error, effectiveTranslate),
1376
1381
  );
1377
1382
  }
1383
+ // Same refetch as EntityListBody's rowActions above.
1384
+ await rowsQuery.refetch();
1378
1385
  },
1379
1386
  ...(writeVisible !== undefined && {
1380
1387
  isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
@@ -1382,7 +1389,7 @@ function ProjectionListBody({
1382
1389
  });
1383
1390
  }
1384
1391
  return out.length > 0 ? out : undefined;
1385
- }, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher]);
1392
+ }, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher, rowsQuery.refetch]);
1386
1393
 
1387
1394
  const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
1388
1395
  if (screen.toolbarActions === undefined) return undefined;
@@ -1419,11 +1426,13 @@ function ProjectionListBody({
1419
1426
  dispatcherErrorText(result.error, effectiveTranslate),
1420
1427
  );
1421
1428
  }
1429
+ // Same refetch as rowActions above.
1430
+ await rowsQuery.refetch();
1422
1431
  },
1423
1432
  });
1424
1433
  }
1425
1434
  return out.length > 0 ? out : undefined;
1426
- }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
1435
+ }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch]);
1427
1436
 
1428
1437
  if (rowsQuery.loading && rowsQuery.data === null) {
1429
1438
  return (
@@ -473,8 +473,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
473
473
  // on every parent render, not just on a snapshot change, risking a loop if
474
474
  // the caller's onChange triggers a parent re-render. Held in a ref like
475
475
  // onChangeRef so only a real snapshot mutation retriggers this effect.
476
- const onControlsReadyRef = useRef(onControlsReady);
477
- onControlsReadyRef.current = onControlsReady;
476
+ // Guards against redelivering to the SAME callback identity on every
477
+ // render (an inline-arrow onControlsReady would otherwise refire the
478
+ // effect below on every render since the callback itself is now a dep).
479
+ // Tracks the actual prop, not a ref snapshot, so a caller that swaps in a
480
+ // real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
481
+ // gets delivered to for THIS mount instead of never (fw#1899).
482
+ const deliveredControlsToRef = useRef<typeof onControlsReady>(undefined);
478
483
  const scopeFieldNamesRef = useRef(scopeFieldNames);
479
484
  scopeFieldNamesRef.current = scopeFieldNames;
480
485
  const scopedValidate = useCallback(
@@ -499,7 +504,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
499
504
  const draftSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
500
505
  useEffect(() => {
501
506
  return () => {
502
- if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
507
+ if (draftSaveTimerRef.current !== null) {
508
+ clearTimeout(draftSaveTimerRef.current);
509
+ // Flush the pending debounced save instead of dropping it — a
510
+ // navigation/unmount inside the debounce window would otherwise
511
+ // silently discard the last patch() (discardDraft nulls this ref
512
+ // first on the post-submit path, so this can't resurrect a
513
+ // just-submitted draft).
514
+ saveDraftRef.current(currentStepRef.current);
515
+ }
503
516
  };
504
517
  }, []);
505
518
 
@@ -529,9 +542,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
529
542
  // available on the very first onChange call, not just from the second
530
543
  // keystroke onward.
531
544
  useEffect(() => {
532
- const cb = onControlsReadyRef.current;
533
- if (cb === undefined) return;
534
- cb({
545
+ if (onControlsReady === undefined) return;
546
+ if (deliveredControlsToRef.current === onControlsReady) return;
547
+ deliveredControlsToRef.current = onControlsReady;
548
+ onControlsReady({
535
549
  patch: patchAndScheduleDraftSave,
536
550
  validate: scopedValidate,
537
551
  getValues: () => controller.getSnapshot().values,
@@ -539,9 +553,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
539
553
  });
540
554
  // controller is mount-lifetime-stable (see useForm's comment on its own
541
555
  // useMemo), same for patchAndScheduleDraftSave/scopedValidate (both
542
- // useCallback over mount-stable deps) — this fires exactly once per
543
- // RenderEdit mount in practice.
544
- }, [controller, scopedValidate, patchAndScheduleDraftSave]);
556
+ // useCallback over mount-stable deps) — onControlsReady is the only dep
557
+ // that can legitimately change post-mount, and the guard above stops an
558
+ // unstable inline-arrow identity from redelivering on every render.
559
+ }, [onControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
545
560
 
546
561
  const schemaRef = useRef(schema);
547
562
  schemaRef.current = schema;
@@ -647,6 +662,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
647
662
  // (see draftCandidates below) — adopt it the same way a single
648
663
  // auto-adopted candidate would be.
649
664
  function adoptDraft(candidate: DraftCandidate): void {
665
+ // Locked state (#1896/fw#1909): `disabled` means "no write possible" —
666
+ // repointing draftKey and patching in the candidate's values is exactly
667
+ // that, so a locked form must not adopt a draft.
668
+ if (disabled) return;
650
669
  const adoptedId = candidate.draftKey.slice(newDraftPrefix(screen.id).length);
651
670
  draftStorage.setDraftId(screen.id, adoptedId);
652
671
  setDraftId(adoptedId);
@@ -666,7 +685,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
666
685
  );
667
686
 
668
687
  const filteredSections = useMemo(
669
- () => filterEditSections(vm.sections, fieldsFilter),
688
+ // A fully-hidden "fields" section (every field in it currently
689
+ // condition-hidden) must not occupy a wizard step; it would render
690
+ // empty and block Back/Next on nothing. Extension sections carry no
691
+ // `visible` (they own their own dirty/save lifecycle), so they always
692
+ // pass through. A section with no fields at all (e.g. a review-only
693
+ // step) has `visible: fields.some(...)` = false vacuously; that's "no
694
+ // fields to hide", not "hidden", so it stays too (fw#1901).
695
+ () =>
696
+ filterEditSections(vm.sections, fieldsFilter).filter(
697
+ (section) => section.kind === "extension" || section.fields.length === 0 || section.visible,
698
+ ),
670
699
  [vm.sections, fieldsFilter],
671
700
  );
672
701
 
@@ -889,10 +918,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
889
918
  let extensionsPersisted = true;
890
919
  if (result.isSuccess) {
891
920
  setFormError(null);
892
- // Awaited, not fire-and-forget: `onSubmit` typically navigates away and
893
- // unmounts this form, which would abort an in-flight discard and leave
894
- // the draft behind after a successful submit.
895
- await discardDraft();
921
+ // `isNoOp: true` (payloadMode "changes", pre-filled form submitted
922
+ // untouched) means controller.submit() never called dispatcher.write
923
+ // nothing to discard, and discarding here would delete a draft the
924
+ // form never persisted (fw#1978). Awaited, not fire-and-forget:
925
+ // `onSubmit` typically navigates away and unmounts this form, which
926
+ // would abort an in-flight discard and leave the draft behind after
927
+ // a successful submit.
928
+ if (result.isNoOp !== true) await discardDraft();
896
929
  extensionsPersisted = await persistExtensions();
897
930
  } else if (result.validationBlocked) {
898
931
  // Root-level `.refine()`/cross-field issues from controller.validate()
@@ -959,6 +992,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
959
992
  type="button"
960
993
  variant="danger"
961
994
  testId="render-edit-delete"
995
+ disabled={disabled}
962
996
  onClick={() => setConfirmDeleteOpen(true)}
963
997
  >
964
998
  {translate("kumiko.actions.delete")}
@@ -89,6 +89,11 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
89
89
  // Widgets — StepBar screen-reader text for completed steps whose number is visually replaced by a checkmark.
90
90
  "kumiko.widget.step-bar.done": "Erledigt",
91
91
 
92
+ // Widgets — Drawer resize handle + maximize toggle aria-labels.
93
+ "kumiko.widget.drawer.restore": "Drawer-Breite zurücksetzen",
94
+ "kumiko.widget.drawer.maximize": "Drawer maximieren",
95
+ "kumiko.widget.drawer.resize": "Drawer-Größe ändern",
96
+
92
97
  // Nav — Sidebar Tree (Toggle-aria-Labels).
93
98
  "kumiko.nav.expand": "Aufklappen",
94
99
  "kumiko.nav.collapse": "Zuklappen",
@@ -269,6 +274,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
269
274
 
270
275
  "kumiko.widget.step-bar.done": "Done",
271
276
 
277
+ "kumiko.widget.drawer.restore": "Restore drawer width",
278
+ "kumiko.widget.drawer.maximize": "Maximize drawer width",
279
+ "kumiko.widget.drawer.resize": "Resize drawer",
280
+
272
281
  "kumiko.nav.expand": "Expand",
273
282
  "kumiko.nav.collapse": "Collapse",
274
283
  "kumiko.nav.search": "Search navigation…",