@cosmicdrift/kumiko-renderer 0.220.1 → 0.221.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.
@@ -64,7 +64,7 @@ const FORM_DRAFT_KEY_MAX_LENGTH = 256;
64
64
  // (the #1888 derived-field shape) — 500ms collapses a typing burst into one
65
65
  // save instead of one per keystroke, while still saving well before a user
66
66
  // abandons the tab.
67
- const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
67
+ export const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
68
68
 
69
69
  type FormDraftBlob = {
70
70
  readonly values: Record<string, unknown>;
@@ -190,10 +190,6 @@ function ExtensionSectionMount({
190
190
  );
191
191
  }
192
192
 
193
- // One header action + its own busy/confirm state — same pattern as
194
- // render-list.tsx's ToolbarActionView (each RenderEditAction is
195
- // independently bound by the caller, there is no shared trigger pipeline
196
- // to hook into like the built-in onDelete/onSubmit paths have).
197
193
  export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
198
194
  props: RenderEditProps<TValues, TCtx>,
199
195
  ): ReactNode {
@@ -301,9 +297,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
301
297
  const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
302
298
 
303
299
  // Must be computed before submitConfig/useForm bakes it in (the controller
304
- // freezes it on first render) safe because a section's field-name set is
305
- // value-independent, so it matches filterEditSections(vm.sections, fieldsFilter).
306
- // undefined = unscoped, since scoping would silently drop root-level .refine() issues.
300
+ // freezes it on first render). This is the unfiltered field-name set from
301
+ // `fieldsFilter` `fields` not the value-dependent `filteredSections`
302
+ // (which also drops `section.visible === false`). Hidden fields still fall
303
+ // out via form-controller's `hiddenFields` path. undefined = unscoped, since
304
+ // scoping would silently drop root-level .refine() issues.
307
305
  // A `fieldsFilter` that matches nothing in `fields` (typo, renamed field)
308
306
  // must fall back to unscoped too — an empty scope array would filter out
309
307
  // ALL validation issues (kumiko-framework#1907), letting submit() through
@@ -361,11 +359,6 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
361
359
  // re-subscribes with a "new" onChange → refires.
362
360
  const onChangeRef = useRef(onChange);
363
361
  onChangeRef.current = onChange;
364
- // `schema` is typically an inline caller prop (`schema={z.object({...})}`)
365
- // with unstable identity across renders — as an effect dep it would refire
366
- // on every parent render, not just on a snapshot change, risking a loop if
367
- // the caller's onChange triggers a parent re-render. Held in a ref like
368
- // onChangeRef so only a real snapshot mutation retriggers this effect.
369
362
  // Guards against redelivering to the SAME callback identity on every
370
363
  // render (an inline-arrow onControlsReady would otherwise refire the
371
364
  // effect below on every render since the callback itself is now a dep).
@@ -373,6 +366,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
373
366
  // real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
374
367
  // gets delivered to for THIS mount instead of never (fw#1899).
375
368
  const deliveredControlsToRef = useRef<typeof onControlsReady>(undefined);
369
+ const onControlsReadyRef = useRef(onControlsReady);
370
+ onControlsReadyRef.current = onControlsReady;
371
+ const hasNavigatedRef = useRef(false);
372
+ const submittedRef = useRef(false);
373
+ const isSubmittingRef = useRef(false);
376
374
  const scopeFieldNamesRef = useRef(scopeFieldNames);
377
375
  scopeFieldNamesRef.current = scopeFieldNames;
378
376
  const scopedValidate = useCallback(
@@ -416,14 +414,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
416
414
  const patchAndScheduleDraftSave = useCallback(
417
415
  (partial: Partial<TValues>) => {
418
416
  controller.setValues(partial);
419
- if (!draftEnabled || dispatcher === undefined) return;
417
+ if (!draftEnabled || dispatcher === undefined || disabled || submittedRef.current) return;
420
418
  if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
421
419
  draftSaveTimerRef.current = setTimeout(() => {
422
420
  draftSaveTimerRef.current = null;
421
+ if (disabled || submittedRef.current) return;
423
422
  saveDraftRef.current(currentStepRef.current);
424
423
  }, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
425
424
  },
426
- [controller, draftEnabled, dispatcher],
425
+ [controller, draftEnabled, dispatcher, disabled],
427
426
  );
428
427
 
429
428
  // Runs before the onChange effect below (declaration order = React
@@ -434,23 +433,27 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
434
433
  // value that should derive dependent fields on mount needs controls.patch
435
434
  // available on the very first onChange call, not just from the second
436
435
  // keystroke onward.
436
+ const hasControlsReady = onControlsReady !== undefined;
437
437
  useEffect(() => {
438
- if (onControlsReady === undefined) return;
439
- if (deliveredControlsToRef.current === onControlsReady) return;
440
- deliveredControlsToRef.current = onControlsReady;
441
- onControlsReady({
438
+ if (!hasControlsReady) return;
439
+ const ready = onControlsReadyRef.current;
440
+ if (ready === undefined) return;
441
+ if (deliveredControlsToRef.current === ready) return;
442
+ deliveredControlsToRef.current = ready;
443
+ ready({
442
444
  patch: patchAndScheduleDraftSave,
443
445
  validate: scopedValidate,
444
446
  getValues: () => controller.getSnapshot().values,
445
447
  submit: () => handleSubmitRef.current(),
446
448
  });
447
- // controller is mount-lifetime-stable (see useForm's comment on its own
448
- // useMemo), same for patchAndScheduleDraftSave/scopedValidate (both
449
- // useCallback over mount-stable deps) — onControlsReady is the only dep
450
- // that can legitimately change post-mount, and the guard above stops an
451
- // unstable inline-arrow identity from redelivering on every render.
452
- }, [onControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
449
+ // Dep on boolean presence only inline-arrow identity must not redeliver.
450
+ }, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
453
451
 
452
+ // `schema` is typically an inline caller prop (`schema={z.object({...})}`)
453
+ // with unstable identity across renders — as an effect dep it would refire
454
+ // on every parent render, not just on a snapshot change, risking a loop if
455
+ // the caller's onChange triggers a parent re-render. Held in a ref like
456
+ // onChangeRef so only a real snapshot mutation retriggers this effect.
454
457
  const schemaRef = useRef(schema);
455
458
  schemaRef.current = schema;
456
459
  // Controls are guaranteed ready by the time this fires (see the
@@ -489,23 +492,28 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
489
492
  if (draftId !== null && draftId === mintedDraftIdRef.current) return;
490
493
  let cancelled = false;
491
494
  void (async () => {
492
- const result = await dispatcher.query<{ readonly draft: FormDraftBlob | null }>(
493
- FORM_DRAFT_GET,
494
- { draftKey },
495
- );
496
- // skip: superseded by a newer mount, or the draft lookup failed — an
497
- // unreachable draft store must not break the form itself.
498
- if (cancelled || !result.isSuccess) return;
499
- const draft = result.data?.draft ?? null;
500
- // skip: nothing saved yet for this key.
501
- if (draft === null) return;
502
- // skip: the user already typed while the lookup was in flight — their
503
- // input wins over the stored draft.
504
- if (controller.getSnapshot().isDirty) return;
505
- // @cast-boundary form-draft blob: `values` round-trips through an opaque
506
- // jsonb column and comes back untyped.
507
- controller.setValues(draft.values as Partial<TValues>);
508
- setRawStep(Math.max(draft.stepIndex, 0));
495
+ try {
496
+ const result = await dispatcher.query<{ readonly draft: FormDraftBlob | null }>(
497
+ FORM_DRAFT_GET,
498
+ { draftKey },
499
+ );
500
+ // skip: superseded by a newer mount, or the draft lookup failed — an
501
+ // unreachable draft store must not break the form itself.
502
+ if (cancelled || !result.isSuccess) return;
503
+ const draft = result.data?.draft ?? null;
504
+ // skip: nothing saved yet for this key.
505
+ if (draft === null) return;
506
+ // skip: the user already typed or wizard-navigated while the lookup
507
+ // was in flight — their input wins over the stored draft.
508
+ if (controller.getSnapshot().isDirty || hasNavigatedRef.current) return;
509
+ // @cast-boundary form-draft blob: `values` round-trips through an opaque
510
+ // jsonb column and comes back untyped.
511
+ controller.setValues(draft.values as Partial<TValues>);
512
+ setRawStep(Math.max(draft.stepIndex, 0));
513
+ } catch (err: unknown) {
514
+ // biome-ignore lint/suspicious/noConsole: draft restore must not red-screen the form
515
+ console.warn("render-edit: draft restore query failed", err);
516
+ }
509
517
  })();
510
518
  return () => {
511
519
  cancelled = true;
@@ -533,24 +541,29 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
533
541
  dispatcher === undefined
534
542
  )
535
543
  return;
536
- didListRef.current = true;
537
544
  let cancelled = false;
538
545
  void (async () => {
539
- const result = await dispatcher.query<{ readonly drafts: readonly DraftCandidate[] }>(
540
- FORM_DRAFT_LIST,
541
- { screenId: screen.id },
542
- );
543
- // skip: superseded by a newer mount, or the lookup failed — an
544
- // unreachable draft store must not break a fresh create.
545
- if (cancelled || !result.isSuccess) return;
546
- const prefix = newDraftPrefix(screen.id);
547
- // list's LIKE-prefix scan also matches edit-mode drafts
548
- // (`${screenId}:${entityId}`) keep only create-mode ones.
549
- const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
550
- // skip: no open drafts for this screen stay null, saveDraft mints a
551
- // fresh draftId on the first step change same as any other create.
552
- if (candidates.length === 0) return;
553
- setDraftCandidates(candidates);
546
+ try {
547
+ const result = await dispatcher.query<{ readonly drafts: readonly DraftCandidate[] }>(
548
+ FORM_DRAFT_LIST,
549
+ { screenId: screen.id },
550
+ );
551
+ // skip: superseded by a newer mount, or the lookup failed — an
552
+ // unreachable draft store must not break a fresh create.
553
+ if (cancelled || !result.isSuccess) return;
554
+ didListRef.current = true;
555
+ const prefix = newDraftPrefix(screen.id);
556
+ // list's LIKE-prefix scan also matches edit-mode drafts
557
+ // (`${screenId}:${entityId}`)keep only create-mode ones.
558
+ const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
559
+ // skip: no open drafts for this screen — stay null, saveDraft mints a
560
+ // fresh draftId on the first step change same as any other create.
561
+ if (candidates.length === 0) return;
562
+ setDraftCandidates(candidates);
563
+ } catch (err: unknown) {
564
+ // biome-ignore lint/suspicious/noConsole: draft lookup must not red-screen the form
565
+ console.warn("render-edit: draft list query failed", err);
566
+ }
554
567
  })();
555
568
  return () => {
556
569
  cancelled = true;
@@ -671,11 +684,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
671
684
  );
672
685
  return;
673
686
  }
674
- void dispatcher.write(FORM_DRAFT_SAVE, {
675
- draftKey: key,
676
- values: controller.getSnapshot().values,
677
- stepIndex,
678
- });
687
+ void dispatcher
688
+ .write(FORM_DRAFT_SAVE, {
689
+ draftKey: key,
690
+ values: controller.getSnapshot().values,
691
+ stepIndex,
692
+ })
693
+ .catch((err: unknown) => {
694
+ // biome-ignore lint/suspicious/noConsole: fire-and-forget draft save must not red-screen
695
+ console.warn("render-edit: draft save failed", err);
696
+ });
679
697
  }
680
698
 
681
699
  // Next: scoped validate() on the current step's fields — errors stay
@@ -684,6 +702,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
684
702
  // controls.validate() API), so no call, no clearing of unrelated errors.
685
703
  function handleWizardNext(): void {
686
704
  const next = Math.min(currentStep + 1, lastStepIndex);
705
+ hasNavigatedRef.current = true;
687
706
  // Locked state (#1896): `disabled` means "no input/no write", not "no
688
707
  // navigation" — a disabled wizard must still be steppable so Back/Next
689
708
  // reach every step. But neither validate() (would block on an empty
@@ -703,6 +722,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
703
722
 
704
723
  function handleWizardBack(): void {
705
724
  const previous = Math.max(currentStep - 1, 0);
725
+ hasNavigatedRef.current = true;
726
+ if (disabled) {
727
+ setRawStep(previous);
728
+ return;
729
+ }
706
730
  setRawStep(previous);
707
731
  saveDraft(previous);
708
732
  }
@@ -770,6 +794,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
770
794
  // native form submit (Enter key) reaches this handler regardless of the
771
795
  // button's disabled attribute — block it here too, not just in the UI.
772
796
  if (disabled) return;
797
+ // Sync guard — React state `isSubmitting` is too late for double-clicks
798
+ // in the same tick (customSubmit has no submitInFlight of its own).
799
+ if (isSubmittingRef.current) return;
800
+ isSubmittingRef.current = true;
773
801
  setIsSubmitting(true);
774
802
  setExtensionErrorKey(null);
775
803
  try {
@@ -779,7 +807,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
779
807
  if (snapshot.isUnchanged && extensionDirty) {
780
808
  // Same discard as the main path: an extension-only save is still a
781
809
  // successful submit, so the draft must not survive it.
782
- if (await persistExtensions()) await discardDraft();
810
+ if (await persistExtensions()) {
811
+ submittedRef.current = true;
812
+ await discardDraft();
813
+ }
783
814
  return;
784
815
  }
785
816
  let result: SubmitResult<unknown>;
@@ -829,7 +860,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
829
860
  // `onSubmit` typically navigates away and unmounts this form, which
830
861
  // would abort an in-flight discard and leave the draft behind after
831
862
  // a successful submit.
832
- if (result.isNoOp !== true) await discardDraft();
863
+ if (result.isNoOp !== true) {
864
+ submittedRef.current = true;
865
+ await discardDraft();
866
+ }
833
867
  extensionsPersisted = await persistExtensions();
834
868
  } else if (result.validationBlocked) {
835
869
  // Root-level `.refine()`/cross-field issues from controller.validate()
@@ -867,6 +901,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
867
901
  // and would unmount it before the user sees the failure).
868
902
  if (shouldNotifyCaller(result, extensionsPersisted)) onSubmit?.(result);
869
903
  } finally {
904
+ isSubmittingRef.current = false;
870
905
  setIsSubmitting(false);
871
906
  }
872
907
  }
@@ -33,14 +33,18 @@ describe("useForm — mounted without a DispatcherProvider", () => {
33
33
  expect(result.current.snapshot.values.title).toBe("");
34
34
  });
35
35
 
36
- test("submit() without an explicit dispatcher rejects instead of crashing the hook", async () => {
36
+ test("submit() without an explicit dispatcher returns isSuccess:false instead of crashing the hook", async () => {
37
37
  const { result } = renderHook(() =>
38
38
  useForm({ initial: { title: "hello" }, submit: { type: "app:write:task:create" } }),
39
39
  );
40
40
 
41
- await expect(result.current.controller.submit()).rejects.toThrow(
42
- /submit\(\) called without a dispatcher/,
43
- );
41
+ const submitResult = await result.current.controller.submit();
42
+ expect(submitResult.validationBlocked).toBe(false);
43
+ expect(submitResult.isSuccess).toBe(false);
44
+ if (submitResult.isSuccess || submitResult.validationBlocked) {
45
+ throw new Error("expected write failure");
46
+ }
47
+ expect(submitResult.error.message).toMatch(/submit\(\) called without a dispatcher/);
44
48
  });
45
49
 
46
50
  test("submit() with an explicit dispatcher still works without a provider", async () => {
package/src/i18n.tsx CHANGED
@@ -193,6 +193,48 @@ function interpolate(template: string, params?: Readonly<Record<string, unknown>
193
193
  });
194
194
  }
195
195
 
196
+ /** Non-throwing LocaleContext read — undefined outside LocaleProvider.
197
+ * Used by DataTable FormatCell so plain tables without a provider do not crash. */
198
+ export function useOptionalLocale(): string | undefined {
199
+ const ctx = useContext(LocaleContext);
200
+ useSyncExternalStore(
201
+ (onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
202
+ () => (ctx ? ctx.resolver.locale() : "en"),
203
+ () => "en",
204
+ );
205
+ return ctx === undefined ? undefined : ctx.resolver.locale();
206
+ }
207
+
208
+ /** Non-throwing translate — undefined outside LocaleProvider. */
209
+ export function useOptionalTranslation():
210
+ | ((key: string, params?: Readonly<Record<string, unknown>>) => string)
211
+ | undefined {
212
+ const ctx = useContext(LocaleContext);
213
+ const locale = useSyncExternalStore(
214
+ (onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
215
+ () => (ctx ? ctx.resolver.locale() : "en"),
216
+ () => "en",
217
+ );
218
+ const t = useCallback(
219
+ (key: string, params?: Readonly<Record<string, unknown>>): string => {
220
+ if (ctx === undefined) return key;
221
+ const resolved = ctx.resolver.translate(key, params);
222
+ if (resolved !== key) return resolved;
223
+ const languageRoot = locale.split("-")[0] ?? locale;
224
+ const localesToTry = [locale, languageRoot, ctx.fallbackLocale];
225
+ for (const bundle of ctx.fallbackBundles) {
226
+ for (const localeToTry of localesToTry) {
227
+ const value = bundle[localeToTry]?.[key];
228
+ if (value !== undefined) return interpolate(value, params);
229
+ }
230
+ }
231
+ return key;
232
+ },
233
+ [ctx, locale],
234
+ );
235
+ return ctx === undefined ? undefined : t;
236
+ }
237
+
196
238
  /** Default-Resolver für Apps ohne eigene i18n-Schicht. Gibt jeden Key
197
239
  * unverändert zurück — die Plugin-Fallback-Bundles erledigen dann die
198
240
  * echte Übersetzung. Nützlich auch als Basis für Tests. */
package/src/index.ts CHANGED
@@ -158,6 +158,8 @@ export {
158
158
  mergeTranslations,
159
159
  translationsByLocaleFromKeys,
160
160
  useLocale,
161
+ useOptionalLocale,
162
+ useOptionalTranslation,
161
163
  useTranslation,
162
164
  } from "./i18n";
163
165
  export { kumikoDefaultTranslations } from "./i18n-defaults";
@@ -35,7 +35,7 @@ import type {
35
35
  ConfigScope,
36
36
  ConfigValueSource,
37
37
  } from "@cosmicdrift/kumiko-framework/engine";
38
- import type { FormWidth } from "@cosmicdrift/kumiko-framework/ui-types";
38
+ import type { FieldIconKey, FormWidth } from "@cosmicdrift/kumiko-framework/ui-types";
39
39
  import type {
40
40
  FieldIssue,
41
41
  ListColumnViewModel,
@@ -179,9 +179,8 @@ export type InputProps =
179
179
  /** Read-only Input (z.B. gewürfelter Free-Tier-Slug). Nicht `disabled`
180
180
  * — bleibt fokussier-/kopierbar. */
181
181
  readonly readOnly?: boolean;
182
- /** Symbolic icon key (FIELD_ICONS registry, renderer-web) — renders
183
- * as a prefix on the input. Unknown key → no icon (no boot-fail). */
184
- readonly icon?: string;
182
+ /** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
183
+ readonly icon?: FieldIconKey;
185
184
  }
186
185
  | {
187
186
  readonly kind: "email";
@@ -223,9 +222,8 @@ export type InputProps =
223
222
  readonly required?: boolean;
224
223
  readonly hasError?: boolean;
225
224
  readonly testId?: string;
226
- /** Symbolic icon key (FIELD_ICONS registry, renderer-web) — renders
227
- * as a prefix on the input. Unknown key → no icon (no boot-fail). */
228
- readonly icon?: string;
225
+ /** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
226
+ readonly icon?: FieldIconKey;
229
227
  /** `<input step>`. "any" disables the native stepMismatch constraint
230
228
  * (needed for decimal fields — integer fields leave this unset). */
231
229
  readonly step?: number | "any";