Skip to content

Commit f03d99d

Browse files
Merge pull request #198 from RtlZeroMemory/guardrails/form-validation
fix(core): stop swallowing async form validation errors
2 parents 927e008 + 582589c commit f03d99d

File tree

3 files changed

+86
-13
lines changed

3 files changed

+86
-13
lines changed

packages/core/src/forms/__tests__/form.async-validation.test.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,17 @@ describe("form.async-validation - utility behavior", () => {
110110
assert.deepEqual(result, { username: "Taken" });
111111
});
112112

113-
test("runAsyncValidation swallows validator rejection", async () => {
114-
const result = await runAsyncValidation({ username: "x" }, async () =>
115-
Promise.reject(new Error("network")),
113+
test("runAsyncValidation wraps validator rejection", async () => {
114+
await assert.rejects(
115+
runAsyncValidation({ username: "x" }, async () => Promise.reject(new Error("network"))),
116+
(error: unknown) => {
117+
assert.ok(error instanceof Error);
118+
assert.equal(error.message, "async form validation failed: network");
119+
assert.ok((error as { cause?: unknown }).cause instanceof Error);
120+
assert.equal(((error as { cause?: unknown }).cause as Error).message, "network");
121+
return true;
122+
},
116123
);
117-
assert.deepEqual(result, {});
118124
});
119125

120126
test("createDebouncedAsyncValidator executes only after debounce window", async () => {
@@ -259,6 +265,36 @@ describe("form.async-validation - utility behavior", () => {
259265
timers.restore();
260266
}
261267
});
268+
269+
test("createDebouncedAsyncValidator ignores rejected in-flight results after cancel", async () => {
270+
const timers = useFakeTimers();
271+
try {
272+
const deferred = createDeferred<ValidationResult<Values>>();
273+
const results: ValidationResult<Values>[] = [];
274+
const errors: unknown[] = [];
275+
const validator = createDebouncedAsyncValidator<Values>(
276+
async () => deferred.promise,
277+
0,
278+
(value) => {
279+
results.push(value);
280+
},
281+
(error) => {
282+
errors.push(error);
283+
},
284+
);
285+
286+
validator.run({ username: "late", email: "" });
287+
timers.tick(0);
288+
validator.cancel();
289+
deferred.reject(new Error("late failure"));
290+
await flushMicrotasks();
291+
292+
assert.equal(results.length, 0);
293+
assert.equal(errors.length, 0);
294+
} finally {
295+
timers.restore();
296+
}
297+
});
262298
});
263299

264300
describe("form.async-validation - useForm behavior", () => {
@@ -422,7 +458,7 @@ describe("form.async-validation - useForm behavior", () => {
422458
assert.equal(form.errors.email, "taken");
423459
});
424460

425-
test("handleSubmit continues when async validation throws network error", async () => {
461+
test("handleSubmit aborts submit when async validation throws network error", async () => {
426462
const h = createFormHarness();
427463
let submitCalls = 0;
428464
const opts = options({
@@ -434,11 +470,13 @@ describe("form.async-validation - useForm behavior", () => {
434470
},
435471
});
436472

437-
const form = h.render(opts);
473+
let form = h.render(opts);
438474
form.handleSubmit();
439475
await flushMicrotasks(4);
476+
form = h.render(opts);
440477

441-
assert.equal(submitCalls, 1);
478+
assert.equal(submitCalls, 0);
479+
assert.equal(form.isSubmitting, false);
442480
});
443481

444482
test("handleSubmit rejects concurrent submits while pending", async () => {

packages/core/src/forms/useForm.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ import {
3535
runSyncValidation,
3636
} from "./validation.js";
3737

38+
const NODE_ENV =
39+
(globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV ??
40+
"development";
41+
const DEV_MODE = NODE_ENV !== "production";
42+
43+
function warnDev(message: string): void {
44+
if (!DEV_MODE) return;
45+
const c = (globalThis as { console?: { warn?: (msg: string) => void } }).console;
46+
c?.warn?.(message);
47+
}
48+
3849
type FieldOverrides<T extends Record<string, unknown>> = Partial<Record<keyof T, boolean>>;
3950

4051
function cloneInitialValues<T extends Record<string, unknown>>(values: T): T {
@@ -66,6 +77,24 @@ function createInitialState<T extends Record<string, unknown>>(
6677
const stepCount = options.wizard?.steps.length ?? 0;
6778
const initialStep = clampStepIndex(options.wizard?.initialStep ?? 0, stepCount);
6879

80+
if (DEV_MODE) {
81+
const valueKeys = new Set(Object.keys(options.initialValues));
82+
if (options.fieldDisabled) {
83+
for (const key of Object.keys(options.fieldDisabled)) {
84+
if (!valueKeys.has(key)) {
85+
warnDev(`[rezi] useForm: fieldDisabled key "${key}" does not exist in initialValues`);
86+
}
87+
}
88+
}
89+
if (options.fieldReadOnly) {
90+
for (const key of Object.keys(options.fieldReadOnly)) {
91+
if (!valueKeys.has(key)) {
92+
warnDev(`[rezi] useForm: fieldReadOnly key "${key}" does not exist in initialValues`);
93+
}
94+
}
95+
}
96+
}
97+
6998
return {
7099
values: cloneInitialValues(options.initialValues),
71100
errors: {},

packages/core/src/forms/validation.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,14 @@ export function isValidationClean<T extends Record<string, unknown>>(
100100
* @param validateAsync - Async validation function
101101
* @param debounceMs - Debounce delay in milliseconds
102102
* @param onResult - Callback when validation completes
103+
* @param onError - Optional callback when async validation throws; if omitted the error is swallowed and onResult receives an empty result
103104
* @returns Object with run and cancel methods
104105
*/
105106
export function createDebouncedAsyncValidator<T extends Record<string, unknown>>(
106107
validateAsync: (values: T) => Promise<ValidationResult<T>>,
107108
debounceMs: number,
108109
onResult: (errors: ValidationResult<T>) => void,
110+
onError?: (error: unknown) => void,
109111
): Readonly<{
110112
run: (values: T) => void;
111113
cancel: () => void;
@@ -134,9 +136,11 @@ export function createDebouncedAsyncValidator<T extends Record<string, unknown>>
134136
onResult(errors);
135137
}
136138
})
137-
.catch(() => {
138-
// Swallow async validation errors - form remains valid
139-
if (!cancelled && myToken === token) {
139+
.catch((e) => {
140+
if (cancelled || myToken !== token) return;
141+
if (onError) {
142+
onError(e);
143+
} else {
140144
onResult({});
141145
}
142146
});
@@ -172,8 +176,10 @@ export async function runAsyncValidation<T extends Record<string, unknown>>(
172176

173177
try {
174178
return await validateAsync(values);
175-
} catch {
176-
// Async validation errors are swallowed - form remains valid
177-
return {};
179+
} catch (e) {
180+
const detail = e instanceof Error ? e.message : String(e);
181+
const wrapped = new Error(`async form validation failed: ${detail}`);
182+
(wrapped as { cause?: unknown }).cause = e;
183+
throw wrapped;
178184
}
179185
}

0 commit comments

Comments
 (0)