@form-engine-ts/react 2.8.0 → 2.9.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/README.md CHANGED
@@ -128,3 +128,9 @@ The Builder basic-settings section edits source `title` and `description` throug
128
128
  Submission confirmation slots receive the effective message, localized schema, and visible answers. An `onSubmit` result
129
129
  may provide `submissionId` and `submittedAt`, which Renderer copies into its receipt. Receipt stores support `getBatch`,
130
130
  and `useSubmissionReceipts` loads multiple form/version receipts for list and dashboard surfaces.
131
+
132
+ Receipt persistence is best-effort: `onReceiptError` observes storage failures while the successful completion screen is
133
+ preserved. Pass an SSR-safe `createLocalStorageSubmissionAttemptStore()` as `attemptStore` to reserve an ID immediately
134
+ before submission. Renderer injects it as `attemptId` and `submissionId`, retains it after a failed request, promotes it
135
+ to the receipt after success, and then clears the attempt. Custom receipt stores may omit `getBatch`; the hook falls back
136
+ to concurrent `get` calls.
package/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  FormBuilder: () => FormBuilder,
24
24
  FormProvider: () => FormProvider,
25
25
  FormRenderer: () => FormRenderer,
26
+ createLocalStorageSubmissionAttemptStore: () => createLocalStorageSubmissionAttemptStore,
26
27
  createLocalStorageSubmissionReceiptStore: () => createLocalStorageSubmissionReceiptStore,
27
28
  resolveInitialFieldType: () => resolveInitialFieldType,
28
29
  submissionReceiptQueryKey: () => submissionReceiptQueryKey,
@@ -33,6 +34,105 @@ __export(index_exports, {
33
34
  });
34
35
  module.exports = __toCommonJS(index_exports);
35
36
 
37
+ // src/attempt.ts
38
+ function attemptKey(namespace, formId, formVersion) {
39
+ return `${namespace}:${formId}:v${formVersion}`;
40
+ }
41
+ function browserStorage() {
42
+ if (typeof window === "undefined") return null;
43
+ try {
44
+ return window.localStorage;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+ function parseAttempt(serialized) {
50
+ try {
51
+ const value = JSON.parse(serialized);
52
+ if (typeof value !== "object" || value === null || !("attemptId" in value) || typeof value.attemptId !== "string" || value.attemptId.length === 0 || !("formId" in value) || typeof value.formId !== "string" || !("formVersion" in value) || typeof value.formVersion !== "number" || !Number.isSafeInteger(value.formVersion) || !("createdAt" in value) || typeof value.createdAt !== "string" || !Number.isFinite(Date.parse(value.createdAt))) {
53
+ return null;
54
+ }
55
+ return {
56
+ attemptId: value.attemptId,
57
+ formId: value.formId,
58
+ formVersion: value.formVersion,
59
+ createdAt: value.createdAt
60
+ };
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+ function defaultAttemptId() {
66
+ const randomUuid = globalThis.crypto?.randomUUID;
67
+ if (typeof randomUuid === "function") return randomUuid.call(globalThis.crypto);
68
+ return `attempt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
69
+ }
70
+ function createLocalStorageSubmissionAttemptStore(options = {}) {
71
+ const namespace = options.namespace ?? "form_engine_attempt";
72
+ if (namespace.trim().length === 0) throw new TypeError("Attempt namespace must not be empty.");
73
+ const memory = /* @__PURE__ */ new Map();
74
+ const inFlight = /* @__PURE__ */ new Map();
75
+ const get = async (formId, formVersion) => {
76
+ const key = attemptKey(namespace, formId, formVersion);
77
+ const remembered = memory.get(key);
78
+ if (remembered !== void 0) return remembered;
79
+ const storage = browserStorage();
80
+ if (storage === null) return null;
81
+ try {
82
+ const serialized = storage.getItem(key);
83
+ if (serialized === null) return null;
84
+ const attempt = parseAttempt(serialized);
85
+ if (attempt === null || attempt.formId !== formId || attempt.formVersion !== formVersion) return null;
86
+ memory.set(key, attempt);
87
+ return attempt;
88
+ } catch {
89
+ return null;
90
+ }
91
+ };
92
+ return {
93
+ get,
94
+ async getOrCreate(formId, formVersion, idFactory = defaultAttemptId) {
95
+ const key = attemptKey(namespace, formId, formVersion);
96
+ const pending = inFlight.get(key);
97
+ if (pending !== void 0) return pending;
98
+ const create = (async () => {
99
+ const existing = await get(formId, formVersion);
100
+ if (existing !== null) return existing;
101
+ const attemptId = idFactory();
102
+ if (typeof attemptId !== "string" || attemptId.trim().length === 0) {
103
+ throw new TypeError("Attempt idFactory must return a non-empty string.");
104
+ }
105
+ const attempt = {
106
+ attemptId,
107
+ formId,
108
+ formVersion,
109
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
110
+ };
111
+ memory.set(key, attempt);
112
+ try {
113
+ browserStorage()?.setItem(key, JSON.stringify(attempt));
114
+ } catch {
115
+ }
116
+ return attempt;
117
+ })();
118
+ inFlight.set(key, create);
119
+ try {
120
+ return await create;
121
+ } finally {
122
+ inFlight.delete(key);
123
+ }
124
+ },
125
+ async clear(formId, formVersion) {
126
+ const key = attemptKey(namespace, formId, formVersion);
127
+ memory.delete(key);
128
+ try {
129
+ browserStorage()?.removeItem(key);
130
+ } catch {
131
+ }
132
+ }
133
+ };
134
+ }
135
+
36
136
  // src/builder.tsx
37
137
  var import_core2 = require("@form-engine-ts/core");
38
138
  var import_react2 = require("react");
@@ -2320,7 +2420,7 @@ function FormProvider({
2320
2420
  setSubmitError(null);
2321
2421
  }, [initialValues]);
2322
2422
  const submit = (0, import_react3.useCallback)(
2323
- async (beforeSubmit) => {
2423
+ async (beforeSubmit, prepareSubmission) => {
2324
2424
  if (submissionInFlight.current) return { status: "cancelled" };
2325
2425
  const validation = (0, import_core3.validateAnswers)(validSchema, values);
2326
2426
  if (!validation.valid) {
@@ -2341,7 +2441,8 @@ function FormProvider({
2341
2441
  setSubmitStatus("idle");
2342
2442
  return { status: "cancelled" };
2343
2443
  }
2344
- const response = await onSubmit(visibleValues);
2444
+ const submissionValues = prepareSubmission === void 0 ? visibleValues : await prepareSubmission(visibleValues);
2445
+ const response = await onSubmit(submissionValues);
2345
2446
  if (resetOnSuccess) setValues({ ...initialValues });
2346
2447
  setSubmitStatus("success");
2347
2448
  return response === void 0 ? { status: "success" } : { status: "success", response };
@@ -2420,7 +2521,7 @@ function submissionReceiptQueryKey(formId, formVersion) {
2420
2521
  function receiptKey(namespace, formId, formVersion) {
2421
2522
  return `${namespace}:${formId}:v${formVersion}`;
2422
2523
  }
2423
- function browserStorage() {
2524
+ function browserStorage2() {
2424
2525
  if (typeof window === "undefined") return null;
2425
2526
  try {
2426
2527
  return window.localStorage;
@@ -2449,7 +2550,7 @@ function createLocalStorageSubmissionReceiptStore(options = {}) {
2449
2550
  const namespace = options.namespace ?? "form_engine_receipt";
2450
2551
  if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
2451
2552
  const get = async (formId, formVersion) => {
2452
- const storage = browserStorage();
2553
+ const storage = browserStorage2();
2453
2554
  if (storage === null) return null;
2454
2555
  try {
2455
2556
  const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
@@ -2471,12 +2572,12 @@ function createLocalStorageSubmissionReceiptStore(options = {}) {
2471
2572
  );
2472
2573
  },
2473
2574
  async save(receipt) {
2474
- const storage = browserStorage();
2575
+ const storage = browserStorage2();
2475
2576
  if (storage === null) return;
2476
2577
  storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
2477
2578
  },
2478
2579
  async remove(formId, formVersion) {
2479
- const storage = browserStorage();
2580
+ const storage = browserStorage2();
2480
2581
  if (storage === null) return;
2481
2582
  storage.removeItem(receiptKey(namespace, formId, formVersion));
2482
2583
  }
@@ -2505,7 +2606,14 @@ function useSubmissionReceipts(store, queries) {
2505
2606
  };
2506
2607
  }
2507
2608
  setState((current) => ({ ...current, isLoading: true, error: null }));
2508
- void store.getBatch(stableQueries).then((receipts) => {
2609
+ const load = store.getBatch?.(stableQueries) ?? Promise.all(stableQueries.map((query) => store.get(query.formId, query.formVersion))).then(
2610
+ (receipts) => new Map(
2611
+ receipts.flatMap(
2612
+ (receipt) => receipt === null ? [] : [[submissionReceiptQueryKey(receipt.formId, receipt.formVersion), receipt]]
2613
+ )
2614
+ )
2615
+ );
2616
+ void load.then((receipts) => {
2509
2617
  if (active) setState({ receipts, isLoading: false, error: null });
2510
2618
  }).catch((cause) => {
2511
2619
  if (!active) return;
@@ -2749,6 +2857,8 @@ function ContextFormRenderer({
2749
2857
  onDraftSave,
2750
2858
  submissionGuards = [],
2751
2859
  receiptStore,
2860
+ attemptStore,
2861
+ onReceiptError,
2752
2862
  slots = {}
2753
2863
  }) {
2754
2864
  const form = useForm();
@@ -2906,7 +3016,18 @@ function ContextFormRenderer({
2906
3016
  setGuardMessage(null);
2907
3017
  rendererSubmissionInFlight.current = true;
2908
3018
  try {
2909
- const result = await form.submit(beforeSubmit);
3019
+ let submissionAttempt;
3020
+ const result = await form.submit(
3021
+ beforeSubmit,
3022
+ attemptStore === void 0 ? void 0 : async (values) => {
3023
+ submissionAttempt = await attemptStore.getOrCreate(form.schema.id, form.schema.version);
3024
+ return {
3025
+ ...values,
3026
+ attemptId: submissionAttempt.attemptId,
3027
+ submissionId: submissionAttempt.attemptId
3028
+ };
3029
+ }
3030
+ );
2910
3031
  if (result.status === "invalid") {
2911
3032
  const invalidPageIndex = pages?.findIndex(
2912
3033
  (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
@@ -2918,14 +3039,29 @@ function ContextFormRenderer({
2918
3039
  if (result.status !== "success") return result;
2919
3040
  if (receiptStore !== void 0) {
2920
3041
  const response = result.response;
3042
+ const submissionId = response?.submissionId ?? submissionAttempt?.attemptId;
2921
3043
  const storedReceipt = {
2922
3044
  formId: form.schema.id,
2923
3045
  formVersion: form.schema.version,
2924
3046
  submittedAt: response?.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2925
- ...response?.submissionId === void 0 ? {} : { submissionId: response.submissionId }
3047
+ ...submissionId === void 0 ? {} : { submissionId }
2926
3048
  };
2927
- await receiptStore.save(storedReceipt);
2928
- setReceipt(storedReceipt);
3049
+ try {
3050
+ await receiptStore.save(storedReceipt);
3051
+ setReceipt(storedReceipt);
3052
+ } catch (cause) {
3053
+ const error = cause instanceof Error ? cause : new Error(String(cause));
3054
+ try {
3055
+ onReceiptError?.(error, storedReceipt);
3056
+ } catch {
3057
+ }
3058
+ }
3059
+ }
3060
+ if (attemptStore !== void 0 && submissionAttempt !== void 0) {
3061
+ try {
3062
+ await attemptStore.clear(form.schema.id, form.schema.version);
3063
+ } catch {
3064
+ }
2929
3065
  }
2930
3066
  if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
2931
3067
  globalThis.localStorage.removeItem(autoSaveKey);
@@ -3145,6 +3281,7 @@ function FormRenderer(props) {
3145
3281
  FormBuilder,
3146
3282
  FormProvider,
3147
3283
  FormRenderer,
3284
+ createLocalStorageSubmissionAttemptStore,
3148
3285
  createLocalStorageSubmissionReceiptStore,
3149
3286
  resolveInitialFieldType,
3150
3287
  submissionReceiptQueryKey,
package/dist/index.d.cts CHANGED
@@ -3,6 +3,21 @@ import { ReactNode, ComponentType, MouseEvent } from 'react';
3
3
  import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, FieldOption, TranslationReport, ValidationError, FormValues, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, ValidationIssue, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
4
4
  import { SensitiveDataFinding } from '@form-engine-ts/privacy';
5
5
 
6
+ interface SubmissionAttempt {
7
+ readonly attemptId: string;
8
+ readonly formId: string;
9
+ readonly formVersion: number;
10
+ readonly createdAt: string;
11
+ }
12
+ interface SubmissionAttemptStore {
13
+ getOrCreate(formId: string, formVersion: number, idFactory?: () => string): Promise<SubmissionAttempt>;
14
+ get(formId: string, formVersion: number): Promise<SubmissionAttempt | null>;
15
+ clear(formId: string, formVersion: number): Promise<void>;
16
+ }
17
+ declare function createLocalStorageSubmissionAttemptStore(options?: {
18
+ readonly namespace?: string;
19
+ }): SubmissionAttemptStore;
20
+
6
21
  /** @deprecated Import FormPolicy from @form-engine-ts/core instead. */
7
22
  type BuilderPolicy = FormPolicy;
8
23
  type BuilderIdKind = "field" | "option" | "page";
@@ -97,7 +112,7 @@ interface SubmissionReceiptQuery {
97
112
  }
98
113
  interface SubmissionReceiptStore {
99
114
  get(formId: string, formVersion: number): Promise<SubmissionReceipt | null>;
100
- getBatch(queries: readonly SubmissionReceiptQuery[]): Promise<Map<string, SubmissionReceipt>>;
115
+ getBatch?(queries: readonly SubmissionReceiptQuery[]): Promise<Map<string, SubmissionReceipt>>;
101
116
  save(receipt: SubmissionReceipt): Promise<void>;
102
117
  remove(formId: string, formVersion: number): Promise<void>;
103
118
  }
@@ -356,6 +371,8 @@ interface FormRendererSlots {
356
371
  interface SubmissionProtectionProps {
357
372
  readonly submissionGuards?: readonly SubmissionGuard[];
358
373
  readonly receiptStore?: SubmissionReceiptStore;
374
+ readonly attemptStore?: SubmissionAttemptStore;
375
+ readonly onReceiptError?: (error: Error, receipt: SubmissionReceipt) => void;
359
376
  }
360
377
  type BeforeSubmit = (values: Readonly<Record<string, unknown>>) => "continue" | "cancel" | Promise<"continue" | "cancel">;
361
378
 
@@ -403,7 +420,7 @@ interface FormContextValue {
403
420
  readonly restoreValues: (values: FormValues) => void;
404
421
  readonly validatePage: (pageIndex: number) => AnswerValidationResult;
405
422
  readonly reset: () => void;
406
- readonly submit: (beforeSubmit?: BeforeSubmit) => Promise<SubmitResult>;
423
+ readonly submit: (beforeSubmit?: BeforeSubmit, prepareSubmission?: (values: FormValues) => FormValues | Promise<FormValues>) => Promise<SubmitResult>;
407
424
  readonly translate: (key: string, params?: Readonly<Record<string, string | number>>) => string;
408
425
  }
409
426
  interface FormProviderProps {
@@ -458,4 +475,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
458
475
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
459
476
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
460
477
 
461
- export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormSubmitHandler, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
478
+ export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormSubmitHandler, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
package/dist/index.d.ts CHANGED
@@ -3,6 +3,21 @@ import { ReactNode, ComponentType, MouseEvent } from 'react';
3
3
  import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, FieldOption, TranslationReport, ValidationError, FormValues, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, ValidationIssue, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
4
4
  import { SensitiveDataFinding } from '@form-engine-ts/privacy';
5
5
 
6
+ interface SubmissionAttempt {
7
+ readonly attemptId: string;
8
+ readonly formId: string;
9
+ readonly formVersion: number;
10
+ readonly createdAt: string;
11
+ }
12
+ interface SubmissionAttemptStore {
13
+ getOrCreate(formId: string, formVersion: number, idFactory?: () => string): Promise<SubmissionAttempt>;
14
+ get(formId: string, formVersion: number): Promise<SubmissionAttempt | null>;
15
+ clear(formId: string, formVersion: number): Promise<void>;
16
+ }
17
+ declare function createLocalStorageSubmissionAttemptStore(options?: {
18
+ readonly namespace?: string;
19
+ }): SubmissionAttemptStore;
20
+
6
21
  /** @deprecated Import FormPolicy from @form-engine-ts/core instead. */
7
22
  type BuilderPolicy = FormPolicy;
8
23
  type BuilderIdKind = "field" | "option" | "page";
@@ -97,7 +112,7 @@ interface SubmissionReceiptQuery {
97
112
  }
98
113
  interface SubmissionReceiptStore {
99
114
  get(formId: string, formVersion: number): Promise<SubmissionReceipt | null>;
100
- getBatch(queries: readonly SubmissionReceiptQuery[]): Promise<Map<string, SubmissionReceipt>>;
115
+ getBatch?(queries: readonly SubmissionReceiptQuery[]): Promise<Map<string, SubmissionReceipt>>;
101
116
  save(receipt: SubmissionReceipt): Promise<void>;
102
117
  remove(formId: string, formVersion: number): Promise<void>;
103
118
  }
@@ -356,6 +371,8 @@ interface FormRendererSlots {
356
371
  interface SubmissionProtectionProps {
357
372
  readonly submissionGuards?: readonly SubmissionGuard[];
358
373
  readonly receiptStore?: SubmissionReceiptStore;
374
+ readonly attemptStore?: SubmissionAttemptStore;
375
+ readonly onReceiptError?: (error: Error, receipt: SubmissionReceipt) => void;
359
376
  }
360
377
  type BeforeSubmit = (values: Readonly<Record<string, unknown>>) => "continue" | "cancel" | Promise<"continue" | "cancel">;
361
378
 
@@ -403,7 +420,7 @@ interface FormContextValue {
403
420
  readonly restoreValues: (values: FormValues) => void;
404
421
  readonly validatePage: (pageIndex: number) => AnswerValidationResult;
405
422
  readonly reset: () => void;
406
- readonly submit: (beforeSubmit?: BeforeSubmit) => Promise<SubmitResult>;
423
+ readonly submit: (beforeSubmit?: BeforeSubmit, prepareSubmission?: (values: FormValues) => FormValues | Promise<FormValues>) => Promise<SubmitResult>;
407
424
  readonly translate: (key: string, params?: Readonly<Record<string, string | number>>) => string;
408
425
  }
409
426
  interface FormProviderProps {
@@ -458,4 +475,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
458
475
  type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
459
476
  declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
460
477
 
461
- export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormSubmitHandler, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
478
+ export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormSubmitHandler, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
package/dist/index.js CHANGED
@@ -1,3 +1,102 @@
1
+ // src/attempt.ts
2
+ function attemptKey(namespace, formId, formVersion) {
3
+ return `${namespace}:${formId}:v${formVersion}`;
4
+ }
5
+ function browserStorage() {
6
+ if (typeof window === "undefined") return null;
7
+ try {
8
+ return window.localStorage;
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+ function parseAttempt(serialized) {
14
+ try {
15
+ const value = JSON.parse(serialized);
16
+ if (typeof value !== "object" || value === null || !("attemptId" in value) || typeof value.attemptId !== "string" || value.attemptId.length === 0 || !("formId" in value) || typeof value.formId !== "string" || !("formVersion" in value) || typeof value.formVersion !== "number" || !Number.isSafeInteger(value.formVersion) || !("createdAt" in value) || typeof value.createdAt !== "string" || !Number.isFinite(Date.parse(value.createdAt))) {
17
+ return null;
18
+ }
19
+ return {
20
+ attemptId: value.attemptId,
21
+ formId: value.formId,
22
+ formVersion: value.formVersion,
23
+ createdAt: value.createdAt
24
+ };
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+ function defaultAttemptId() {
30
+ const randomUuid = globalThis.crypto?.randomUUID;
31
+ if (typeof randomUuid === "function") return randomUuid.call(globalThis.crypto);
32
+ return `attempt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
33
+ }
34
+ function createLocalStorageSubmissionAttemptStore(options = {}) {
35
+ const namespace = options.namespace ?? "form_engine_attempt";
36
+ if (namespace.trim().length === 0) throw new TypeError("Attempt namespace must not be empty.");
37
+ const memory = /* @__PURE__ */ new Map();
38
+ const inFlight = /* @__PURE__ */ new Map();
39
+ const get = async (formId, formVersion) => {
40
+ const key = attemptKey(namespace, formId, formVersion);
41
+ const remembered = memory.get(key);
42
+ if (remembered !== void 0) return remembered;
43
+ const storage = browserStorage();
44
+ if (storage === null) return null;
45
+ try {
46
+ const serialized = storage.getItem(key);
47
+ if (serialized === null) return null;
48
+ const attempt = parseAttempt(serialized);
49
+ if (attempt === null || attempt.formId !== formId || attempt.formVersion !== formVersion) return null;
50
+ memory.set(key, attempt);
51
+ return attempt;
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+ return {
57
+ get,
58
+ async getOrCreate(formId, formVersion, idFactory = defaultAttemptId) {
59
+ const key = attemptKey(namespace, formId, formVersion);
60
+ const pending = inFlight.get(key);
61
+ if (pending !== void 0) return pending;
62
+ const create = (async () => {
63
+ const existing = await get(formId, formVersion);
64
+ if (existing !== null) return existing;
65
+ const attemptId = idFactory();
66
+ if (typeof attemptId !== "string" || attemptId.trim().length === 0) {
67
+ throw new TypeError("Attempt idFactory must return a non-empty string.");
68
+ }
69
+ const attempt = {
70
+ attemptId,
71
+ formId,
72
+ formVersion,
73
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
74
+ };
75
+ memory.set(key, attempt);
76
+ try {
77
+ browserStorage()?.setItem(key, JSON.stringify(attempt));
78
+ } catch {
79
+ }
80
+ return attempt;
81
+ })();
82
+ inFlight.set(key, create);
83
+ try {
84
+ return await create;
85
+ } finally {
86
+ inFlight.delete(key);
87
+ }
88
+ },
89
+ async clear(formId, formVersion) {
90
+ const key = attemptKey(namespace, formId, formVersion);
91
+ memory.delete(key);
92
+ try {
93
+ browserStorage()?.removeItem(key);
94
+ } catch {
95
+ }
96
+ }
97
+ };
98
+ }
99
+
1
100
  // src/builder.tsx
2
101
  import {
3
102
  populateSchemaTranslations
@@ -2299,7 +2398,7 @@ function FormProvider({
2299
2398
  setSubmitError(null);
2300
2399
  }, [initialValues]);
2301
2400
  const submit = useCallback2(
2302
- async (beforeSubmit) => {
2401
+ async (beforeSubmit, prepareSubmission) => {
2303
2402
  if (submissionInFlight.current) return { status: "cancelled" };
2304
2403
  const validation = validateAnswers(validSchema, values);
2305
2404
  if (!validation.valid) {
@@ -2320,7 +2419,8 @@ function FormProvider({
2320
2419
  setSubmitStatus("idle");
2321
2420
  return { status: "cancelled" };
2322
2421
  }
2323
- const response = await onSubmit(visibleValues);
2422
+ const submissionValues = prepareSubmission === void 0 ? visibleValues : await prepareSubmission(visibleValues);
2423
+ const response = await onSubmit(submissionValues);
2324
2424
  if (resetOnSuccess) setValues({ ...initialValues });
2325
2425
  setSubmitStatus("success");
2326
2426
  return response === void 0 ? { status: "success" } : { status: "success", response };
@@ -2399,7 +2499,7 @@ function submissionReceiptQueryKey(formId, formVersion) {
2399
2499
  function receiptKey(namespace, formId, formVersion) {
2400
2500
  return `${namespace}:${formId}:v${formVersion}`;
2401
2501
  }
2402
- function browserStorage() {
2502
+ function browserStorage2() {
2403
2503
  if (typeof window === "undefined") return null;
2404
2504
  try {
2405
2505
  return window.localStorage;
@@ -2428,7 +2528,7 @@ function createLocalStorageSubmissionReceiptStore(options = {}) {
2428
2528
  const namespace = options.namespace ?? "form_engine_receipt";
2429
2529
  if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
2430
2530
  const get = async (formId, formVersion) => {
2431
- const storage = browserStorage();
2531
+ const storage = browserStorage2();
2432
2532
  if (storage === null) return null;
2433
2533
  try {
2434
2534
  const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
@@ -2450,12 +2550,12 @@ function createLocalStorageSubmissionReceiptStore(options = {}) {
2450
2550
  );
2451
2551
  },
2452
2552
  async save(receipt) {
2453
- const storage = browserStorage();
2553
+ const storage = browserStorage2();
2454
2554
  if (storage === null) return;
2455
2555
  storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
2456
2556
  },
2457
2557
  async remove(formId, formVersion) {
2458
- const storage = browserStorage();
2558
+ const storage = browserStorage2();
2459
2559
  if (storage === null) return;
2460
2560
  storage.removeItem(receiptKey(namespace, formId, formVersion));
2461
2561
  }
@@ -2484,7 +2584,14 @@ function useSubmissionReceipts(store, queries) {
2484
2584
  };
2485
2585
  }
2486
2586
  setState((current) => ({ ...current, isLoading: true, error: null }));
2487
- void store.getBatch(stableQueries).then((receipts) => {
2587
+ const load = store.getBatch?.(stableQueries) ?? Promise.all(stableQueries.map((query) => store.get(query.formId, query.formVersion))).then(
2588
+ (receipts) => new Map(
2589
+ receipts.flatMap(
2590
+ (receipt) => receipt === null ? [] : [[submissionReceiptQueryKey(receipt.formId, receipt.formVersion), receipt]]
2591
+ )
2592
+ )
2593
+ );
2594
+ void load.then((receipts) => {
2488
2595
  if (active) setState({ receipts, isLoading: false, error: null });
2489
2596
  }).catch((cause) => {
2490
2597
  if (!active) return;
@@ -2738,6 +2845,8 @@ function ContextFormRenderer({
2738
2845
  onDraftSave,
2739
2846
  submissionGuards = [],
2740
2847
  receiptStore,
2848
+ attemptStore,
2849
+ onReceiptError,
2741
2850
  slots = {}
2742
2851
  }) {
2743
2852
  const form = useForm();
@@ -2895,7 +3004,18 @@ function ContextFormRenderer({
2895
3004
  setGuardMessage(null);
2896
3005
  rendererSubmissionInFlight.current = true;
2897
3006
  try {
2898
- const result = await form.submit(beforeSubmit);
3007
+ let submissionAttempt;
3008
+ const result = await form.submit(
3009
+ beforeSubmit,
3010
+ attemptStore === void 0 ? void 0 : async (values) => {
3011
+ submissionAttempt = await attemptStore.getOrCreate(form.schema.id, form.schema.version);
3012
+ return {
3013
+ ...values,
3014
+ attemptId: submissionAttempt.attemptId,
3015
+ submissionId: submissionAttempt.attemptId
3016
+ };
3017
+ }
3018
+ );
2899
3019
  if (result.status === "invalid") {
2900
3020
  const invalidPageIndex = pages?.findIndex(
2901
3021
  (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
@@ -2907,14 +3027,29 @@ function ContextFormRenderer({
2907
3027
  if (result.status !== "success") return result;
2908
3028
  if (receiptStore !== void 0) {
2909
3029
  const response = result.response;
3030
+ const submissionId = response?.submissionId ?? submissionAttempt?.attemptId;
2910
3031
  const storedReceipt = {
2911
3032
  formId: form.schema.id,
2912
3033
  formVersion: form.schema.version,
2913
3034
  submittedAt: response?.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2914
- ...response?.submissionId === void 0 ? {} : { submissionId: response.submissionId }
3035
+ ...submissionId === void 0 ? {} : { submissionId }
2915
3036
  };
2916
- await receiptStore.save(storedReceipt);
2917
- setReceipt(storedReceipt);
3037
+ try {
3038
+ await receiptStore.save(storedReceipt);
3039
+ setReceipt(storedReceipt);
3040
+ } catch (cause) {
3041
+ const error = cause instanceof Error ? cause : new Error(String(cause));
3042
+ try {
3043
+ onReceiptError?.(error, storedReceipt);
3044
+ } catch {
3045
+ }
3046
+ }
3047
+ }
3048
+ if (attemptStore !== void 0 && submissionAttempt !== void 0) {
3049
+ try {
3050
+ await attemptStore.clear(form.schema.id, form.schema.version);
3051
+ } catch {
3052
+ }
2918
3053
  }
2919
3054
  if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
2920
3055
  globalThis.localStorage.removeItem(autoSaveKey);
@@ -3133,6 +3268,7 @@ export {
3133
3268
  FormBuilder,
3134
3269
  FormProvider,
3135
3270
  FormRenderer,
3271
+ createLocalStorageSubmissionAttemptStore,
3136
3272
  createLocalStorageSubmissionReceiptStore,
3137
3273
  resolveInitialFieldType,
3138
3274
  submissionReceiptQueryKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@form-engine-ts/react",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -42,8 +42,8 @@
42
42
  "typescript"
43
43
  ],
44
44
  "dependencies": {
45
- "@form-engine-ts/core": "2.8.0",
46
- "@form-engine-ts/privacy": "2.8.0"
45
+ "@form-engine-ts/core": "2.9.0",
46
+ "@form-engine-ts/privacy": "2.9.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "react": ">=18.2 <20",