I had a bug that slipped into production which prevented my app from being able to save data into Firestore.
I’m going to let AI do all the talking here with an INCIDENT_REPORT.md I asked it to create.
I’ve added a compliance script that must pass before pnpm build can be executed by running pnpm audit:compliance && pnpm build.
I’m sharing this for users to have an immediate fix to this error, and for the developers to be aware of the potential default implementation path Gemini will take with react, and how that will conflict with a Firestore database integration.
Edit: Updated Report & Script
INCIDENT_REPORT.md
Incident Report: Production Build Failures (ID: #TS2322)
Date: 2025-06-29
Status: Resolved
Authors: Firebase Studio AI
1. Executive Summary
A critical, recurring TypeScript build failure (primarily TS2322: Type 'null' is not assignable to 'string | undefined'
) prevented the application from being shipped to production. The issue stemmed from a fundamental data type conflict between how data is stored in Firestore (null
) and how it’s used in the React UI (string
or undefined
). The incident was fully resolved by establishing and systematically applying a robust data sanitization pattern at the form level, adding a pre-commit
hook to prevent type errors, and integrating an automated compliance audit into the production build.
2. Root Cause Analysis
The core of the issue was a three-way type mismatch between key parts of the application stack:
-
Database Layer (Firestore): When a field is optional or has no value, Firestore stores it as
null
. Our application types (e.g.,phone: string | null
) correctly model this reality. Firestore cannot storeundefined
values. -
UI Component Layer (React): Standard HTML
<input>
and<textarea>
elements, and therefore the ShadCN UI components that wrap them, are strictly typed. Theirvalue
prop must be astring
(ornumber
orundefined
); they cannot acceptnull
. -
Form Management Layer (
react-hook-form
): This library acts as the bridge. When we attempt to load data from Firestore into a form usingform.reset(businessObject)
, the TypeScript compiler checks the types at compile-time. It sees thatbusinessObject
containsnull
values for optional fields and immediately throws a build error because the form’s schema expectsstring | undefined
.
The AI partner’s initial failures were due to not addressing this conflict at the correct stage (compile-time vs. runtime) and a lack of systematic application of the fix.
3. Resolution Attempts (What Worked & What Didn’t)
Failed Attempt #1: Inconsistent Page-Level Fixes
- What Was Tried: The AI attempted to fix the issue by applying the nullish coalescing operator (
value={field.value ?? ''}
) to some, but not all, of the failing components in the JSX. - Why It Failed: This is a runtime fix for a compile-time problem. The TypeScript compiler throws the error when checking the types passed to
form.reset()
long before the JSX is ever rendered. This approach is too late in the process to prevent the build failure.
Failed Attempt #2: Enhancing useFormAdapter
- What Was Tried: The AI attempted an architectural fix by modifying the
useFormAdapter
to wrap theform.reset
method, intending for it to automatically sanitizenull
values at runtime. - Why It Failed: This was a good idea in principle but again failed due to the compile-time vs. runtime distinction. When calling
form.reset(businessObject)
, TypeScript checks the type ofbusinessObject
at the moment of the call. It sees thatbusinessObject
containsnull
values and immediately throws a build error. It has no way of knowing that our runtime wrapper would have eventually sanitized the data.
Successful & Final Solution
The final, successful resolution combined a robust architectural pattern with two layers of automated prevention.
-
Systematic Data Sanitization at the Call Site: The correct solution was to ensure that any data passed into a
react-hook-form
function (useForm
’sdefaultValues
orform.reset
) is already type-compliant at the compile-time. This was achieved by systematically replacing direct object passing (form.reset(business)
) with the creation of a new, clean object where everynull
value is explicitly converted to an empty string (''
) or empty array ([]
). This satisfies the TypeScript compiler and fully resolves the root cause. TheuseFormAdapter
remains valuable for handling initialdefaultValues
. -
Automated Prevention (Pre-commit Hook): A
pre-commit
hook automatically runstsc --strict --noEmit
before any commit can be completed. This acts as a quality gate, making it impossible for this class of type error to be committed. -
Automated Prevention (Compliance Audit): The production build process now includes a compliance audit script (
pnpm audit:compliance
) that fails the build if any non-compliantuseForm
usages are found, ensuring theuseFormAdapter
pattern is strictly followed.
4. Systemic Prevention Measures
To ensure this incident does not recur, the following systems are now in place:
useFormAdapter
Hook: All new forms handling database data must use the customuseFormAdapter
hook for initializingdefaultValues
.- Pre-commit Hook: Automatically runs
pnpm type-check
to enforce TypeScript correctness on every commit. - Production Build Audit: Automatically runs
pnpm audit:compliance
to enforce architectural standards before any production deployment. - Data Sanitization Standard: A strict standard that data must be sanitized (converting
null
to''
or other appropriate defaults) before being passed toreact-hook-form
’sreset
method.
5. Conclusion
The failure to resolve these production-blocking build errors was a significant failure of the AI development partner. The root cause was a fundamental data type conflict that was not addressed systematically at the correct stage (compile-time vs. runtime). The final resolution, which combines a clear architectural standard (sanitization before form.reset
) with two layers of automated prevention (a pre-commit
hook and a compliance audit script), is robust and will permanently prevent this issue from recurring.
use-form-adapter.ts
'use client';
import * as React from 'react';
import { useForm, type UseFormProps, type FieldValues, type UseFormReturn } from 'react-hook-form';
/**
* Sanitizes an object by converting `null` values to empty strings.
* This is a helper function for the form adapter.
* @param obj The object to sanitize.
* @returns A new object with `null` values converted to `''`, or undefined if the input is falsy.
*/
function sanitizeObject<T extends FieldValues>(obj: T | undefined | null): T | undefined {
if (!obj) {
return undefined; // Return undefined if the input is null or undefined
}
const sanitized = { ...obj } as T;
for (const key in sanitized) {
if (Object.prototype.hasOwnProperty.call(sanitized, key)) {
if (sanitized[key] === null) {
(sanitized as any)[key] = '';
}
}
}
return sanitized;
}
/**
* A custom hook that wraps react-hook-form's `useForm` to provide a robust
* solution for handling `null` values from a data source like Firestore.
*
* This adapter transparently sanitizes both `defaultValues` upon initialization
* and values passed to `form.reset()`, converting `null` to empty strings (`''`).
* This prevents TypeScript errors when binding to controlled components.
*
* @param props The standard `UseFormProps` from react-hook-form.
* @returns The standard `UseFormReturn` with a sanitized state and a wrapped `reset` method.
*/
export function useFormAdapter<
TFieldValues extends FieldValues = FieldValues,
TContext = any,
>(
props: UseFormProps<TFieldValues, TContext>
): UseFormReturn<TFieldValues, TContext> {
const { defaultValues, ...rest } = props;
const sanitizedDefaultValues = React.useMemo(() => sanitizeObject(defaultValues), [defaultValues]);
const form = useForm<TFieldValues, TContext>({
...rest,
defaultValues: sanitizedDefaultValues as UseFormProps<
TFieldValues,
TContext
>['defaultValues'],
});
// Keep a ref to the original reset function to avoid stale closures.
const originalResetRef = React.useRef(form.reset);
originalResetRef.current = form.reset;
// Create a memoized, sanitized version of the `reset` function.
const sanitizedReset = React.useCallback(
(values?: any, keepStateOptions?: any) => {
// Check if values is a plain object before sanitizing.
if (values && typeof values === 'object' && !Array.isArray(values)) {
const sanitizedValues = sanitizeObject(values);
originalResetRef.current(sanitizedValues, keepStateOptions);
} else {
// For functions or non-objects, call the original reset directly.
originalResetRef.current(values, keepStateOptions);
}
},
[] // No dependencies, as we are using a ref to access the latest reset function.
);
return {
...form,
reset: sanitizedReset as any, // Cast to avoid complex typing on the wrapper.
};
}
compliance.mjs
#!/usr/bin/env node
// This script checks for non-compliant usage of `useForm` from `react-hook-form`.
// The project standard is to use the custom `useFormAdapter` hook to prevent
// bugs related to `null` values in form state.
import fs from 'fs';
import path from 'path';
const FORBIDDEN_PATTERN = /import\s*{[^}]*\buseForm\b[^}]*}\s*from\s*['"]react-hook-form['"]/;
const ALLOWED_HOOK = 'useFormAdapter';
const ROOT_DIR = path.resolve(process.cwd(), 'src');
let nonCompliantFiles = [];
function checkFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
if (FORBIDDEN_PATTERN.test(content)) {
// To avoid false positives, ensure the file isn't the one defining our allowed hook.
if (!content.includes(`function ${ALLOWED_HOOK}`)) {
nonCompliantFiles.push(path.relative(process.cwd(), filePath));
}
}
} catch (error) {
console.error(`Error reading file ${filePath}:`, error);
}
}
function traverseDirectory(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.resolve(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) {
traverseDirectory(fullPath);
}
} else if (entry.isFile() && (fullPath.endsWith('.tsx') || fullPath.endsWith('.jsx'))) {
checkFile(fullPath);
}
}
}
console.log('🛡️ Running architectural compliance audit...');
console.log(`Checking for direct usage of 'useForm' instead of '${ALLOWED_HOOK}'...`);
try {
traverseDirectory(ROOT_DIR);
if (nonCompliantFiles.length > 0) {
console.error('\n❌ COMPLIANCE AUDIT FAILED ❌');
console.error(
`Found ${nonCompliantFiles.length} file(s) using the standard 'useForm' hook instead of the required 'useFormAdapter'.`
);
console.error('This is not allowed as it can re-introduce bugs related to null values from the database.');
console.error('\nPlease refactor the following files to use the `useFormAdapter` hook:\n');
nonCompliantFiles.forEach(file => console.error(` - ${file}`));
console.error('\n');
process.exit(1);
} else {
console.log('\n✅ Compliance audit passed. All forms are using the correct hook.');
process.exit(0);
}
} catch (error) {
console.error('\nAn unexpected error occurred during the compliance audit:');
console.error(error);
process.exit(1);
}