Skip to content

Commit b447b61

Browse files
Add form state monitoring technique for autofill detection
1 parent 461abfe commit b447b61

3 files changed

Lines changed: 255 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { TextField } from '@lambdacurry/forms/remix-hook-form/text-field';
3+
import { Button } from '@lambdacurry/forms/ui/button';
4+
import { useAutofillFormState } from '@lambdacurry/forms/ui/hooks/use-autofill-form-state';
5+
import type { Meta, StoryObj } from '@storybook/react-vite';
6+
import { type ActionFunctionArgs, useFetcher } from 'react-router';
7+
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
8+
import { z } from 'zod';
9+
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
10+
11+
const formSchema = z.object({
12+
name: z.string().min(1, 'Name is required'),
13+
email: z.string().email('Invalid email address'),
14+
phone: z.string().min(1, 'Phone number is required'),
15+
address: z.string().min(1, 'Address is required'),
16+
});
17+
18+
type FormData = z.infer<typeof formSchema>;
19+
20+
const AutofillFormStateExample = () => {
21+
const fetcher = useFetcher<{ message: string }>();
22+
const methods = useRemixForm<FormData>({
23+
resolver: zodResolver(formSchema),
24+
defaultValues: {
25+
name: '',
26+
email: '',
27+
phone: '',
28+
address: '',
29+
},
30+
fetcher,
31+
submitConfig: {
32+
action: '/',
33+
method: 'post',
34+
},
35+
});
36+
37+
// Use the autofill detection hook for each field
38+
const nameAutofill = useAutofillFormState(methods, 'name');
39+
const emailAutofill = useAutofillFormState(methods, 'email');
40+
const phoneAutofill = useAutofillFormState(methods, 'phone');
41+
const addressAutofill = useAutofillFormState(methods, 'address');
42+
43+
return (
44+
<RemixFormProvider {...methods}>
45+
<fetcher.Form onSubmit={methods.handleSubmit}>
46+
<div className="space-y-4 max-w-md mx-auto">
47+
<h2 className="text-xl font-bold mb-4">Autofill Detection with Form State Monitoring</h2>
48+
<p className="text-sm text-gray-500 mb-6">
49+
This form demonstrates autofill detection by monitoring form state changes.
50+
Try using your browser's autofill feature to populate these fields and watch for the "Autofilled" indicator.
51+
</p>
52+
53+
<div className="space-y-4">
54+
<div className="relative">
55+
<TextField
56+
name="name"
57+
label="Full Name"
58+
autoComplete="name"
59+
/>
60+
{nameAutofill.isAutofilled && (
61+
<div className="absolute right-0 top-0 bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
62+
Autofilled
63+
</div>
64+
)}
65+
</div>
66+
67+
<div className="relative">
68+
<TextField
69+
name="email"
70+
label="Email Address"
71+
type="email"
72+
autoComplete="email"
73+
/>
74+
{emailAutofill.isAutofilled && (
75+
<div className="absolute right-0 top-0 bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
76+
Autofilled
77+
</div>
78+
)}
79+
</div>
80+
81+
<div className="relative">
82+
<TextField
83+
name="phone"
84+
label="Phone Number"
85+
type="tel"
86+
autoComplete="tel"
87+
/>
88+
{phoneAutofill.isAutofilled && (
89+
<div className="absolute right-0 top-0 bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
90+
Autofilled
91+
</div>
92+
)}
93+
</div>
94+
95+
<div className="relative">
96+
<TextField
97+
name="address"
98+
label="Street Address"
99+
autoComplete="street-address"
100+
/>
101+
{addressAutofill.isAutofilled && (
102+
<div className="absolute right-0 top-0 bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
103+
Autofilled
104+
</div>
105+
)}
106+
</div>
107+
108+
<Button type="submit" className="w-full mt-6">
109+
Submit
110+
</Button>
111+
112+
{fetcher.data?.message && (
113+
<p className="mt-2 text-green-600">{fetcher.data.message}</p>
114+
)}
115+
</div>
116+
</div>
117+
</fetcher.Form>
118+
</RemixFormProvider>
119+
);
120+
};
121+
122+
const handleFormSubmission = async (request: Request) => {
123+
const { data, errors } = await getValidatedFormData<FormData>(request, zodResolver(formSchema));
124+
125+
if (errors) {
126+
return { errors };
127+
}
128+
129+
return { message: 'Form submitted successfully' };
130+
};
131+
132+
const meta: Meta<typeof TextField> = {
133+
title: 'RemixHookForm/AutofillFormState',
134+
component: TextField,
135+
parameters: {
136+
layout: 'centered',
137+
docs: {
138+
description: {
139+
component: 'Demonstrates autofill detection by monitoring form state changes.'
140+
}
141+
}
142+
},
143+
tags: ['autodocs'],
144+
};
145+
146+
export default meta;
147+
type Story = StoryObj<typeof meta>;
148+
149+
export const FormStateExample: Story = {
150+
decorators: [
151+
withReactRouterStubDecorator({
152+
routes: [
153+
{
154+
path: '/',
155+
Component: AutofillFormStateExample,
156+
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
157+
},
158+
],
159+
}),
160+
],
161+
};
162+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './use-autofill-form-state';
2+
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { type FieldValues, type UseFormReturn, type FieldPath } from 'react-hook-form';
3+
4+
/**
5+
* Hook to detect browser autofill by monitoring form state changes.
6+
* This technique works by watching for changes in the form state that
7+
* weren't triggered by user interaction.
8+
*
9+
* @param form - The form instance from useForm or useRemixForm
10+
* @param name - The field name to monitor
11+
* @returns Object containing isAutofilled state and reset function
12+
*/
13+
export function useAutofillFormState<
14+
TFieldValues extends FieldValues = FieldValues,
15+
TContext = any
16+
>(
17+
form: UseFormReturn<TFieldValues, TContext>,
18+
name: FieldPath<TFieldValues>
19+
) {
20+
const [isAutofilled, setIsAutofilled] = useState(false);
21+
const userInteractionRef = useRef(false);
22+
const previousValueRef = useRef<any>(form.getValues(name));
23+
const touchedRef = useRef(false);
24+
25+
// Subscribe to form state changes
26+
useEffect(() => {
27+
const subscription = form.watch((values, { name: changedField, type }) => {
28+
// Only process changes for the field we're monitoring
29+
if (changedField !== name) return;
30+
31+
const currentValue = values[name as keyof typeof values];
32+
33+
// Skip if value hasn't changed
34+
if (currentValue === previousValueRef.current) return;
35+
36+
// If the field was changed programmatically (not by user interaction)
37+
// and the value is not empty, it might be autofill
38+
if (
39+
!userInteractionRef.current &&
40+
currentValue &&
41+
type !== 'change' &&
42+
type !== 'blur' &&
43+
!touchedRef.current
44+
) {
45+
setIsAutofilled(true);
46+
}
47+
48+
// Update previous value
49+
previousValueRef.current = currentValue;
50+
});
51+
52+
return () => subscription.unsubscribe();
53+
}, [form, name]);
54+
55+
// Track user interactions with the form
56+
useEffect(() => {
57+
const handleUserInteraction = () => {
58+
userInteractionRef.current = true;
59+
touchedRef.current = true;
60+
61+
// Reset after a short delay to catch only immediate changes
62+
setTimeout(() => {
63+
userInteractionRef.current = false;
64+
}, 50);
65+
};
66+
67+
// Track events that indicate user interaction
68+
document.addEventListener('keydown', handleUserInteraction);
69+
document.addEventListener('input', handleUserInteraction);
70+
document.addEventListener('paste', handleUserInteraction);
71+
document.addEventListener('cut', handleUserInteraction);
72+
document.addEventListener('mouseup', handleUserInteraction);
73+
74+
return () => {
75+
document.removeEventListener('keydown', handleUserInteraction);
76+
document.removeEventListener('input', handleUserInteraction);
77+
document.removeEventListener('paste', handleUserInteraction);
78+
document.removeEventListener('cut', handleUserInteraction);
79+
document.removeEventListener('mouseup', handleUserInteraction);
80+
};
81+
}, []);
82+
83+
// Function to reset the autofilled state
84+
const resetAutofilled = () => {
85+
setIsAutofilled(false);
86+
touchedRef.current = true;
87+
};
88+
89+
return { isAutofilled, resetAutofilled };
90+
}
91+

0 commit comments

Comments
 (0)