@cosmicdrift/kumiko-renderer 0.194.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.194.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.194.0",
19
- "@cosmicdrift/kumiko-headless": "0.194.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"
@@ -58,7 +58,15 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
58
58
 
59
59
  function Probe({ contentFormat }: { readonly contentFormat?: string }): ReactNode {
60
60
  const Editor = useContentEditor(contentFormat);
61
- return <Editor value="hello" onChange={() => {}} variables={[]} readOnly={false} />;
61
+ return (
62
+ <Editor
63
+ id={CONTENT_EDITOR_ELEMENT_ID}
64
+ value="hello"
65
+ onChange={() => {}}
66
+ variables={[]}
67
+ readOnly={false}
68
+ />
69
+ );
62
70
  }
63
71
 
64
72
  describe("useContentEditor", () => {
@@ -102,7 +110,13 @@ describe("TextareaContentEditor", () => {
102
110
  // non-admin viewing read-only content couldn't select/copy it. readOnly
103
111
  // keeps the field focusable and selectable while blocking edits.
104
112
  render(
105
- <TextareaContentEditor value="draft" onChange={() => {}} variables={[]} readOnly={true} />,
113
+ <TextareaContentEditor
114
+ id={CONTENT_EDITOR_ELEMENT_ID}
115
+ value="draft"
116
+ onChange={() => {}}
117
+ variables={[]}
118
+ readOnly={true}
119
+ />,
106
120
  { wrapper: Wrapper },
107
121
  );
108
122
  const el = screen.getByTestId("ca-textarea") as HTMLTextAreaElement;
@@ -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
+ });
@@ -12,6 +12,12 @@ import { type ComponentType, createContext, type ReactNode, useContext } from "r
12
12
  import { usePrimitives } from "../primitives";
13
13
 
14
14
  export type ContentEditorProps = {
15
+ /** DOM id every registered editor must render onto its own focusable
16
+ * root element. Callers that wrap an editor in a `Field` (label +
17
+ * htmlFor) pass a stable id here so the label stays associated with
18
+ * whatever element actually renders — a fixed constant would break as
19
+ * soon as a registered editor swaps in for the textarea fallback. */
20
+ readonly id: string;
15
21
  readonly value: string;
16
22
  readonly onChange: (value: string) => void;
17
23
  /** Variable names insertable as chips (from the collection's
@@ -24,10 +30,10 @@ export type ContentEditorProps = {
24
30
 
25
31
  export type ContentEditorComponent = ComponentType<ContentEditorProps>;
26
32
 
27
- /** Fixed DOM id the fallback textarea renders under. Callers that wrap an
28
- * editor in a `Field` (label + htmlFor) use this as the Field's `id` so the
29
- * label stays associated the editor contract has no `id` prop of its own,
30
- * every registered editor owns its own focusable element's id. */
33
+ /** Default id value for callers that don't generate their own (e.g. a
34
+ * standalone TextareaContentEditor render outside a multi-editor page).
35
+ * Registered editors don't apply this automatically every caller passes
36
+ * its own id via ContentEditorProps.id. */
31
37
  export const CONTENT_EDITOR_ELEMENT_ID = "content-editor-textarea";
32
38
 
33
39
  export type ContentEditorsMap = Readonly<Record<string, ContentEditorComponent>>;
@@ -50,6 +56,7 @@ export function ContentEditorsProvider({
50
56
  * Uses the primitives Input like every other form field, so it renders on
51
57
  * every platform without requiring the platform's DOM/native equivalent. */
52
58
  export function TextareaContentEditor({
59
+ id,
53
60
  value,
54
61
  onChange,
55
62
  readOnly,
@@ -58,7 +65,7 @@ export function TextareaContentEditor({
58
65
  return (
59
66
  <Input
60
67
  kind="textarea"
61
- id={CONTENT_EDITOR_ELEMENT_ID}
68
+ id={id}
62
69
  name={CONTENT_EDITOR_ELEMENT_ID}
63
70
  value={value}
64
71
  onChange={onChange}
@@ -4,7 +4,7 @@
4
4
  // renders formatted HTML and "plain"/"markdown" render as text through the
5
5
  // exact same component, no separate render path per format.
6
6
 
7
- import type { ReactNode } from "react";
7
+ import { type ReactNode, useId } from "react";
8
8
  import { useContentEditor } from "./content-editors";
9
9
 
10
10
  const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}/g;
@@ -36,6 +36,7 @@ export function ContentPreview({
36
36
  contentFormat,
37
37
  }: ContentPreviewProps): ReactNode {
38
38
  const Editor = useContentEditor(contentFormat);
39
+ const id = useId();
39
40
  // "rich" content is HTML (see ContentCollectionDefinition.contentFormat) —
40
41
  // an example value substituted in raw could break the markup (`<`) or
41
42
  // render as an unescaped entity (`&`). "plain"/"markdown" content is text,
@@ -48,6 +49,7 @@ export function ContentPreview({
48
49
  : variables;
49
50
  return (
50
51
  <Editor
52
+ id={id}
51
53
  value={substituteVariables(content, safeVariables)}
52
54
  onChange={noop}
53
55
  variables={[]}
@@ -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",
@@ -26,6 +26,7 @@ import type {
26
26
  } from "@cosmicdrift/kumiko-headless";
27
27
  import { fieldLabelKey } from "@cosmicdrift/kumiko-headless";
28
28
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
29
+ import { extractCreatedId } from "../components/reference-create-dialog";
29
30
  import { RenderEdit } from "../components/render-edit";
30
31
  import { RenderList, type ToolbarActionButton } from "../components/render-list";
31
32
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
@@ -461,7 +462,11 @@ function EntityEditCreateBody({
461
462
  (result: SubmitResult<unknown>) => {
462
463
  if (!result.isSuccess) return;
463
464
  if (screen.redirect !== undefined) {
464
- nav.navigate({ screenId: lastSegment(screen.redirect) });
465
+ const entityId = extractCreatedId(result.data);
466
+ nav.navigate({
467
+ screenId: lastSegment(screen.redirect),
468
+ ...(entityId !== undefined && { entityId }),
469
+ });
465
470
  return;
466
471
  }
467
472
  navigateToList();
@@ -1131,6 +1136,9 @@ function EntityListBody({
1131
1136
  dispatcherErrorText(result.error, effectiveTranslate),
1132
1137
  );
1133
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();
1134
1142
  },
1135
1143
  isVisible:
1136
1144
  writeActionVisible !== undefined
@@ -1139,7 +1147,7 @@ function EntityListBody({
1139
1147
  };
1140
1148
  })
1141
1149
  .filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
1142
- }, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate]);
1150
+ }, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, rowsQuery.refetch]);
1143
1151
 
1144
1152
  // ToolbarActions: Schema → Resolved-Form (analog rowActions).
1145
1153
  // navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
@@ -1181,11 +1189,13 @@ function EntityListBody({
1181
1189
  dispatcherErrorText(result.error, effectiveTranslate),
1182
1190
  );
1183
1191
  }
1192
+ // Same refetch as rowActions above.
1193
+ await rowsQuery.refetch();
1184
1194
  },
1185
1195
  };
1186
1196
  })
1187
1197
  .filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
1188
- }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
1198
+ }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch]);
1189
1199
 
1190
1200
  if (rowsQuery.loading && rowsQuery.data === null) {
1191
1201
  return (
@@ -1370,6 +1380,8 @@ function ProjectionListBody({
1370
1380
  dispatcherErrorText(result.error, effectiveTranslate),
1371
1381
  );
1372
1382
  }
1383
+ // Same refetch as EntityListBody's rowActions above.
1384
+ await rowsQuery.refetch();
1373
1385
  },
1374
1386
  ...(writeVisible !== undefined && {
1375
1387
  isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
@@ -1377,7 +1389,7 @@ function ProjectionListBody({
1377
1389
  });
1378
1390
  }
1379
1391
  return out.length > 0 ? out : undefined;
1380
- }, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher]);
1392
+ }, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher, rowsQuery.refetch]);
1381
1393
 
1382
1394
  const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
1383
1395
  if (screen.toolbarActions === undefined) return undefined;
@@ -1414,11 +1426,13 @@ function ProjectionListBody({
1414
1426
  dispatcherErrorText(result.error, effectiveTranslate),
1415
1427
  );
1416
1428
  }
1429
+ // Same refetch as rowActions above.
1430
+ await rowsQuery.refetch();
1417
1431
  },
1418
1432
  });
1419
1433
  }
1420
1434
  return out.length > 0 ? out : undefined;
1421
- }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
1435
+ }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch]);
1422
1436
 
1423
1437
  if (rowsQuery.loading && rowsQuery.data === null) {
1424
1438
  return (
@@ -23,7 +23,7 @@ function entityWriteCommand(featureName: string, entity: string): string {
23
23
  // The create handler's success payload is `{ kind: "save", id, ... }`
24
24
  // (see event-store-executor-write.ts) but RenderEdit's onSubmit only
25
25
  // types it as `unknown` — narrow defensively instead of casting through it.
26
- function extractCreatedId(data: unknown): string | undefined {
26
+ export function extractCreatedId(data: unknown): string | undefined {
27
27
  if (typeof data !== "object" || data === null) return undefined;
28
28
  const id = (data as Record<string, unknown>)["id"];
29
29
  return typeof id === "string" ? id : undefined;
@@ -25,7 +25,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
25
25
  import type { z } from "zod";
26
26
  import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
27
27
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
28
- import { useDispatcher } from "../context/dispatcher-context";
28
+ import { useOptionalDispatcher } from "../context/dispatcher-context";
29
29
  import { useDraftStorage } from "../context/draft-storage-context";
30
30
  import { formatWhen } from "../format-when";
31
31
  import { useForm } from "../hooks/use-form";
@@ -80,6 +80,14 @@ function newDraftPrefix(screenId: string): string {
80
80
  return `${screenId}:new:`;
81
81
  }
82
82
 
83
+ // `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
84
+ // without a polyfill — guard like dispatcher-live.ts's generateRequestId.
85
+ function mintDraftId(): string {
86
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
87
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
88
+ return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
89
+ }
90
+
83
91
  // End-to-end renderer für einen entityEdit screen. Rendert aus-
84
92
  // schließlich über Primitives — kein raw HTML. Ein Native-Renderer
85
93
  // der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
@@ -343,10 +351,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
343
351
  // ohnehin nur in einem mounted Kumiko-App-Tree läuft.
344
352
  const t = useTranslation();
345
353
  const translate = translateProp ?? t;
346
- const dispatcher = useDispatcher();
354
+ const dispatcher = useOptionalDispatcher();
347
355
 
348
356
  const isWizard = screen.layout.mode === "wizard";
349
- const draftEnabled = isWizard && screen.layout.draft === true;
357
+ const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
350
358
  const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
351
359
  const draftStorage = useDraftStorage();
352
360
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
@@ -465,8 +473,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
465
473
  // on every parent render, not just on a snapshot change, risking a loop if
466
474
  // the caller's onChange triggers a parent re-render. Held in a ref like
467
475
  // onChangeRef so only a real snapshot mutation retriggers this effect.
468
- const onControlsReadyRef = useRef(onControlsReady);
469
- 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);
470
483
  const scopeFieldNamesRef = useRef(scopeFieldNames);
471
484
  scopeFieldNamesRef.current = scopeFieldNames;
472
485
  const scopedValidate = useCallback(
@@ -491,7 +504,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
491
504
  const draftSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
492
505
  useEffect(() => {
493
506
  return () => {
494
- 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
+ }
495
516
  };
496
517
  }, []);
497
518
 
@@ -502,14 +523,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
502
523
  const patchAndScheduleDraftSave = useCallback(
503
524
  (partial: Partial<TValues>) => {
504
525
  controller.setValues(partial);
505
- if (!draftEnabled) return;
526
+ if (!draftEnabled || dispatcher === undefined) return;
506
527
  if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
507
528
  draftSaveTimerRef.current = setTimeout(() => {
508
529
  draftSaveTimerRef.current = null;
509
530
  saveDraftRef.current(currentStepRef.current);
510
531
  }, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
511
532
  },
512
- [controller, draftEnabled],
533
+ [controller, draftEnabled, dispatcher],
513
534
  );
514
535
 
515
536
  // Runs before the onChange effect below (declaration order = React
@@ -521,9 +542,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
521
542
  // available on the very first onChange call, not just from the second
522
543
  // keystroke onward.
523
544
  useEffect(() => {
524
- const cb = onControlsReadyRef.current;
525
- if (cb === undefined) return;
526
- cb({
545
+ if (onControlsReady === undefined) return;
546
+ if (deliveredControlsToRef.current === onControlsReady) return;
547
+ deliveredControlsToRef.current = onControlsReady;
548
+ onControlsReady({
527
549
  patch: patchAndScheduleDraftSave,
528
550
  validate: scopedValidate,
529
551
  getValues: () => controller.getSnapshot().values,
@@ -531,9 +553,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
531
553
  });
532
554
  // controller is mount-lifetime-stable (see useForm's comment on its own
533
555
  // useMemo), same for patchAndScheduleDraftSave/scopedValidate (both
534
- // useCallback over mount-stable deps) — this fires exactly once per
535
- // RenderEdit mount in practice.
536
- }, [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]);
537
560
 
538
561
  const schemaRef = useRef(schema);
539
562
  schemaRef.current = schema;
@@ -556,7 +579,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
556
579
 
557
580
  useEffect(() => {
558
581
  // skip: this screen does not persist a draft.
559
- if (!draftEnabled) return;
582
+ if (!draftEnabled || dispatcher === undefined) return;
560
583
  // skip: create-mode with no draftId yet — nothing to restore, either
561
584
  // the list-fallback effect below finds one or the first step change
562
585
  // mints a fresh one.
@@ -603,7 +626,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
603
626
  // skip: this screen does not persist a draft, this is edit-mode, a
604
627
  // draftId is already known (from storage or an earlier adoption), or
605
628
  // this mount already ran the list lookup once (didListRef).
606
- if (!draftEnabled || !isCreateMode || draftId !== null || didListRef.current) return;
629
+ if (
630
+ !draftEnabled ||
631
+ !isCreateMode ||
632
+ draftId !== null ||
633
+ didListRef.current ||
634
+ dispatcher === undefined
635
+ )
636
+ return;
607
637
  didListRef.current = true;
608
638
  let cancelled = false;
609
639
  void (async () => {
@@ -632,6 +662,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
632
662
  // (see draftCandidates below) — adopt it the same way a single
633
663
  // auto-adopted candidate would be.
634
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;
635
669
  const adoptedId = candidate.draftKey.slice(newDraftPrefix(screen.id).length);
636
670
  draftStorage.setDraftId(screen.id, adoptedId);
637
671
  setDraftId(adoptedId);
@@ -651,7 +685,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
651
685
  );
652
686
 
653
687
  const filteredSections = useMemo(
654
- () => 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
+ ),
655
699
  [vm.sections, fieldsFilter],
656
700
  );
657
701
 
@@ -696,10 +740,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
696
740
  // computed inline instead of read from the memoized `draftKey`.
697
741
  function saveDraft(stepIndex: number): void {
698
742
  // skip: this screen does not persist a draft.
699
- if (!draftEnabled) return;
743
+ if (!draftEnabled || dispatcher === undefined) return;
700
744
  let key = draftKey;
701
745
  if (isCreateMode && draftId === null) {
702
- const mintedId = crypto.randomUUID();
746
+ const mintedId = mintDraftId();
703
747
  mintedDraftIdRef.current = mintedId;
704
748
  draftStorage.setDraftId(screen.id, mintedId);
705
749
  setDraftId(mintedId);
@@ -761,7 +805,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
761
805
 
762
806
  async function discardDraft(): Promise<void> {
763
807
  // skip: this screen does not persist a draft.
764
- if (!draftEnabled) return;
808
+ if (!draftEnabled || dispatcher === undefined) return;
765
809
  // A pending debounced patch-save must not fire after discard — it would
766
810
  // resurrect the draft it just deleted.
767
811
  if (draftSaveTimerRef.current !== null) {
@@ -874,10 +918,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
874
918
  let extensionsPersisted = true;
875
919
  if (result.isSuccess) {
876
920
  setFormError(null);
877
- // Awaited, not fire-and-forget: `onSubmit` typically navigates away and
878
- // unmounts this form, which would abort an in-flight discard and leave
879
- // the draft behind after a successful submit.
880
- 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();
881
929
  extensionsPersisted = await persistExtensions();
882
930
  } else if (result.validationBlocked) {
883
931
  // Root-level `.refine()`/cross-field issues from controller.validate()
@@ -944,6 +992,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
944
992
  type="button"
945
993
  variant="danger"
946
994
  testId="render-edit-delete"
995
+ disabled={disabled}
947
996
  onClick={() => setConfirmDeleteOpen(true)}
948
997
  >
949
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…",