@silverassist/recaptcha 0.1.0 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.1] - 2026-02-06
9
+
10
+ ### Fixed
11
+
12
+ - Fix race condition in lazy mode where `grecaptcha` was unavailable immediately after script load (#15)
13
+ - Fix `act()` warnings in client tests by properly wrapping async callbacks
14
+ - Fix TypeScript lint error for `tagName` property on `Node` type in tests
15
+
16
+ ### Changed
17
+
18
+ - Add JSDoc module headers with `@module`, `@author`, `@license`, and `@version` tags to all source files
19
+ - Document callback stability requirements (`useCallback`) in `onTokenGenerated` and `onError` props
20
+ - Improve test coverage from 82% to 86% branch coverage with edge case tests
21
+ - Suppress expected reCAPTCHA console logs during test execution
22
+ - Update release prompt to include version sync for JSDoc headers
23
+
24
+ ### Dependencies
25
+
26
+ - Bump `@types/node` from 25.0.10 to 25.2.0
27
+ - Bump `@types/react` from 19.2.9 to 19.2.10
28
+ - Bump `next` from 16.1.5 to 16.1.6
29
+
30
+ ## [0.2.0] - 2026-02-02
31
+
32
+ ### Added
33
+
34
+ - **Lazy loading support** for better page performance
35
+ - New `lazy` prop to defer reCAPTCHA script loading until form is visible
36
+ - New `lazyRootMargin` prop to configure IntersectionObserver threshold (default: "200px")
37
+ - Reduces initial JS by ~320KB and improves TBT/TTI metrics
38
+ - **Singleton script loading** prevents duplicate script loads across multiple forms
39
+ - Comprehensive test coverage for lazy loading scenarios
40
+ - Documentation for lazy loading with performance metrics
41
+
42
+ ### Fixed
43
+
44
+ - Add IntersectionObserver feature detection with fallback to eager loading for older browsers/SSR environments
45
+ - Add error handling tests for non-lazy Script component to ensure proper callback notification
46
+ - Queued callbacks now properly receive error notifications on script load failure
47
+
8
48
  ## [0.1.0] - 2026-01-27
9
49
 
10
50
  ### Added
package/README.md CHANGED
@@ -14,6 +14,8 @@ Google reCAPTCHA v3 integration for Next.js applications with Server Actions sup
14
14
  - ✅ **Auto Token Refresh**: Tokens refresh automatically before expiration
15
15
  - ✅ **Graceful Degradation**: Works in development without credentials
16
16
  - ✅ **Configurable Thresholds**: Custom score thresholds per form
17
+ - ✅ **Lazy Loading**: Optional lazy loading for better performance
18
+ - ✅ **Singleton Script Loading**: Prevents duplicate script loads across multiple forms
17
19
 
18
20
  ## Installation
19
21
 
@@ -92,6 +94,197 @@ export async function submitForm(formData: FormData) {
92
94
  }
93
95
  ```
94
96
 
97
+ ## ⚠️ Important: Custom FormData
98
+
99
+ `RecaptchaWrapper` injects a **hidden input field** containing the reCAPTCHA token. If your form handler creates a custom `FormData` object, you must ensure the hidden token is included.
100
+
101
+ ### ❌ This will fail (token is missing):
102
+
103
+ ```tsx
104
+ "use client";
105
+
106
+ import { RecaptchaWrapper } from "@silverassist/recaptcha";
107
+ import { submitForm } from "./actions"; // Your server action
108
+
109
+ export function ContactForm() {
110
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
111
+ e.preventDefault();
112
+
113
+ // ❌ Creating empty FormData - hidden reCAPTCHA input is NOT included!
114
+ const formData = new FormData();
115
+ formData.set("email", "user@example.com");
116
+ formData.set("message", "Hello");
117
+
118
+ await submitForm(formData);
119
+ };
120
+
121
+ return (
122
+ <form onSubmit={handleSubmit}>
123
+ <RecaptchaWrapper action="contact_form" />
124
+ <input name="email" type="email" required />
125
+ <textarea name="message" required />
126
+ <button type="submit">Send</button>
127
+ </form>
128
+ );
129
+ }
130
+ ```
131
+
132
+ ### ✅ Recommended: Pass form element to capture all inputs
133
+
134
+ ```tsx
135
+ "use client";
136
+
137
+ import { RecaptchaWrapper } from "@silverassist/recaptcha";
138
+ import { submitForm } from "./actions"; // Your server action
139
+
140
+ export function ContactForm() {
141
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
142
+ e.preventDefault();
143
+
144
+ // ✅ Pass form element - captures ALL inputs including hidden reCAPTCHA token
145
+ const formData = new FormData(e.currentTarget);
146
+
147
+ await submitForm(formData);
148
+ };
149
+
150
+ return (
151
+ <form onSubmit={handleSubmit}>
152
+ <RecaptchaWrapper action="contact_form" />
153
+ <input name="email" type="email" required />
154
+ <textarea name="message" required />
155
+ <button type="submit">Send</button>
156
+ </form>
157
+ );
158
+ }
159
+ ```
160
+
161
+ ### ✅ Alternative: Start with form element, then modify
162
+
163
+ ```tsx
164
+ "use client";
165
+
166
+ import { RecaptchaWrapper } from "@silverassist/recaptcha";
167
+ import { submitForm } from "./actions"; // Your server action
168
+
169
+ export function ContactForm() {
170
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
171
+ e.preventDefault();
172
+
173
+ // ✅ Start with form element (includes hidden token)
174
+ const formData = new FormData(e.currentTarget);
175
+
176
+ // Then add/override specific fields
177
+ formData.set("customField", "customValue");
178
+ formData.set("timestamp", Date.now().toString());
179
+
180
+ await submitForm(formData);
181
+ };
182
+
183
+ return (
184
+ <form onSubmit={handleSubmit}>
185
+ <RecaptchaWrapper action="contact_form" />
186
+ <input name="email" type="email" required />
187
+ <textarea name="message" required />
188
+ <button type="submit">Send</button>
189
+ </form>
190
+ );
191
+ }
192
+ ```
193
+
194
+ ## Performance Optimization: Lazy Loading
195
+
196
+ The `lazy` prop enables lazy loading of the reCAPTCHA script, which defers loading until the form becomes visible in the viewport. This significantly improves initial page load performance.
197
+
198
+ ### Performance Benefits
199
+
200
+ > **Note**: These metrics are approximate values measured on production websites using Google reCAPTCHA v3. Actual performance improvements will vary based on network conditions, device capabilities, and page complexity.
201
+
202
+ | Metric | Without Lazy Loading | With Lazy Loading | Improvement |
203
+ |--------|---------------------|-------------------|-------------|
204
+ | **Initial JS** | 320KB+ | 0 KB (until visible) | -320KB |
205
+ | **TBT (Total Blocking Time)** | ~470ms | ~0ms (deferred) | -470ms |
206
+ | **TTI (Time to Interactive)** | +2-3s | Minimal impact | -2-3s |
207
+
208
+ ### Basic Lazy Loading
209
+
210
+ Enable lazy loading by adding the `lazy` prop:
211
+
212
+ ```tsx
213
+ "use client";
214
+
215
+ import { RecaptchaWrapper } from "@silverassist/recaptcha";
216
+
217
+ export function ContactForm() {
218
+ return (
219
+ <form action={submitForm}>
220
+ {/* Script loads only when form is near viewport */}
221
+ <RecaptchaWrapper action="contact_form" lazy />
222
+ <input name="email" type="email" required />
223
+ <textarea name="message" required />
224
+ <button type="submit">Send</button>
225
+ </form>
226
+ );
227
+ }
228
+ ```
229
+
230
+ ### Custom Root Margin
231
+
232
+ Control when the script loads with the `lazyRootMargin` prop (default: `"200px"`):
233
+
234
+ ```tsx
235
+ // Load script earlier (400px before entering viewport)
236
+ <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="400px" />
237
+
238
+ // Load script later (load only when fully visible)
239
+ <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="0px" />
240
+
241
+ // Load with negative margin (load only after scrolling past)
242
+ <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="-100px" />
243
+ ```
244
+
245
+ ### Best Practices
246
+
247
+ #### 1. Use lazy loading for below-the-fold forms
248
+
249
+ ```tsx
250
+ // Hero form (above the fold) - load immediately
251
+ <RecaptchaWrapper action="hero_signup" />
252
+
253
+ // Footer form (below the fold) - lazy load
254
+ <RecaptchaWrapper action="footer_contact" lazy />
255
+ ```
256
+
257
+ #### 2. Multiple forms on the same page
258
+
259
+ The package automatically uses singleton script loading, so the script is only loaded once even with multiple forms:
260
+
261
+ ```tsx
262
+ export function MultiFormPage() {
263
+ return (
264
+ <>
265
+ {/* First form triggers script load */}
266
+ <RecaptchaWrapper action="newsletter" lazy />
267
+
268
+ {/* Second form reuses the same script */}
269
+ <RecaptchaWrapper action="contact" lazy />
270
+
271
+ {/* Third form also reuses the script */}
272
+ <RecaptchaWrapper action="feedback" lazy />
273
+ </>
274
+ );
275
+ }
276
+ ```
277
+
278
+ #### 3. Adjust root margin based on form position
279
+
280
+ ```tsx
281
+ // Form near top - smaller margin for faster load
282
+ <RecaptchaWrapper action="signup" lazy lazyRootMargin="100px" />
283
+
284
+ // Form far down page - larger margin to load in advance
285
+ <RecaptchaWrapper action="newsletter" lazy lazyRootMargin="500px" />
286
+ ```
287
+
95
288
  ## API Reference
96
289
 
97
290
  ### RecaptchaWrapper
@@ -107,9 +300,25 @@ Client component that loads reCAPTCHA and generates tokens.
107
300
  refreshInterval={90000} // Optional: token refresh interval in ms (default: 90000)
108
301
  onTokenGenerated={(token) => {}} // Optional: callback when token is generated
109
302
  onError={(error) => {}} // Optional: callback on error
303
+ lazy={false} // Optional: enable lazy loading (default: false)
304
+ lazyRootMargin="200px" // Optional: IntersectionObserver rootMargin (default: "200px")
110
305
  />
111
306
  ```
112
307
 
308
+ #### Props
309
+
310
+ | Prop | Type | Default | Description |
311
+ |------|------|---------|-------------|
312
+ | `action` | `string` | **Required** | Action name for reCAPTCHA analytics (e.g., "contact_form", "signup") |
313
+ | `inputName` | `string` | `"recaptchaToken"` | Name attribute for the hidden input field |
314
+ | `inputId` | `string` | `"recaptcha-token"` | ID attribute for the hidden input field |
315
+ | `siteKey` | `string` | `process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY` | Override the site key from environment variable |
316
+ | `refreshInterval` | `number` | `90000` | Token refresh interval in milliseconds (90 seconds) |
317
+ | `onTokenGenerated` | `(token: string) => void` | `undefined` | Callback invoked when a new token is generated |
318
+ | `onError` | `(error: Error) => void` | `undefined` | Callback invoked when an error occurs |
319
+ | `lazy` | `boolean` | `false` | Enable lazy loading to defer script until form is visible |
320
+ | `lazyRootMargin` | `string` | `"200px"` | IntersectionObserver rootMargin (used when `lazy` is true) |
321
+
113
322
  ### validateRecaptcha
114
323
 
115
324
  Server-side token validation function.
@@ -14,10 +14,34 @@ interface RecaptchaWrapperProps {
14
14
  siteKey?: string;
15
15
  /** Token refresh interval in ms (default: 90000 = 90 seconds) */
16
16
  refreshInterval?: number;
17
- /** Callback when token is generated */
17
+ /**
18
+ * Callback when token is generated.
19
+ * @remarks Should be memoized with useCallback to prevent unnecessary re-renders.
20
+ * @example
21
+ * ```tsx
22
+ * const handleToken = useCallback((token: string) => {
23
+ * console.log('Token:', token);
24
+ * }, []);
25
+ * <RecaptchaWrapper action="form" onTokenGenerated={handleToken} />
26
+ * ```
27
+ */
18
28
  onTokenGenerated?: (token: string) => void;
19
- /** Callback when an error occurs */
29
+ /**
30
+ * Callback when an error occurs.
31
+ * @remarks Should be memoized with useCallback to prevent unnecessary re-renders.
32
+ * @example
33
+ * ```tsx
34
+ * const handleError = useCallback((error: Error) => {
35
+ * console.error('reCAPTCHA error:', error);
36
+ * }, []);
37
+ * <RecaptchaWrapper action="form" onError={handleError} />
38
+ * ```
39
+ */
20
40
  onError?: (error: Error) => void;
41
+ /** Enable lazy loading (default: false for backward compatibility) */
42
+ lazy?: boolean;
43
+ /** IntersectionObserver rootMargin for lazy loading (default: "200px") */
44
+ lazyRootMargin?: string;
21
45
  }
22
46
  /**
23
47
  * Global window interface extension for reCAPTCHA
@@ -30,6 +54,15 @@ declare global {
30
54
  action: string;
31
55
  }) => Promise<string>;
32
56
  };
57
+ /** Flag to track if reCAPTCHA script has loaded */
58
+ __recaptchaLoaded?: boolean;
59
+ /** Flag to track if reCAPTCHA script is currently loading */
60
+ __recaptchaLoading?: boolean;
61
+ /** Callbacks to execute when script finishes loading */
62
+ __recaptchaCallbacks?: Array<{
63
+ onLoad: () => void;
64
+ onError: (error: Error) => void;
65
+ }>;
33
66
  }
34
67
  }
35
68
 
@@ -42,6 +75,7 @@ declare global {
42
75
  * - Refreshes token periodically (tokens expire after 2 minutes)
43
76
  * - Stores token in hidden input field for form submission
44
77
  * - Graceful fallback when not configured
78
+ * - Lazy loading support to defer script loading until visible
45
79
  *
46
80
  * @example Basic usage
47
81
  * ```tsx
@@ -63,13 +97,32 @@ declare global {
63
97
  *
64
98
  * @example With callbacks
65
99
  * ```tsx
100
+ * // IMPORTANT: Memoize callbacks to prevent unnecessary re-renders
101
+ * const handleToken = useCallback((token: string) => {
102
+ * console.log("Token:", token);
103
+ * }, []);
104
+ *
105
+ * const handleError = useCallback((error: Error) => {
106
+ * console.error("Error:", error);
107
+ * }, []);
108
+ *
66
109
  * <RecaptchaWrapper
67
110
  * action="payment"
68
- * onTokenGenerated={(token) => console.log("Token:", token)}
69
- * onError={(error) => console.error("Error:", error)}
111
+ * onTokenGenerated={handleToken}
112
+ * onError={handleError}
70
113
  * />
71
114
  * ```
115
+ *
116
+ * @example Lazy loading for better performance
117
+ * ```tsx
118
+ * <RecaptchaWrapper action="contact_form" lazy />
119
+ * ```
120
+ *
121
+ * @example Lazy loading with custom root margin
122
+ * ```tsx
123
+ * <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="400px" />
124
+ * ```
72
125
  */
73
- declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
126
+ declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, lazy, lazyRootMargin, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
74
127
 
75
128
  export { RecaptchaWrapper, RecaptchaWrapper as default };
@@ -14,10 +14,34 @@ interface RecaptchaWrapperProps {
14
14
  siteKey?: string;
15
15
  /** Token refresh interval in ms (default: 90000 = 90 seconds) */
16
16
  refreshInterval?: number;
17
- /** Callback when token is generated */
17
+ /**
18
+ * Callback when token is generated.
19
+ * @remarks Should be memoized with useCallback to prevent unnecessary re-renders.
20
+ * @example
21
+ * ```tsx
22
+ * const handleToken = useCallback((token: string) => {
23
+ * console.log('Token:', token);
24
+ * }, []);
25
+ * <RecaptchaWrapper action="form" onTokenGenerated={handleToken} />
26
+ * ```
27
+ */
18
28
  onTokenGenerated?: (token: string) => void;
19
- /** Callback when an error occurs */
29
+ /**
30
+ * Callback when an error occurs.
31
+ * @remarks Should be memoized with useCallback to prevent unnecessary re-renders.
32
+ * @example
33
+ * ```tsx
34
+ * const handleError = useCallback((error: Error) => {
35
+ * console.error('reCAPTCHA error:', error);
36
+ * }, []);
37
+ * <RecaptchaWrapper action="form" onError={handleError} />
38
+ * ```
39
+ */
20
40
  onError?: (error: Error) => void;
41
+ /** Enable lazy loading (default: false for backward compatibility) */
42
+ lazy?: boolean;
43
+ /** IntersectionObserver rootMargin for lazy loading (default: "200px") */
44
+ lazyRootMargin?: string;
21
45
  }
22
46
  /**
23
47
  * Global window interface extension for reCAPTCHA
@@ -30,6 +54,15 @@ declare global {
30
54
  action: string;
31
55
  }) => Promise<string>;
32
56
  };
57
+ /** Flag to track if reCAPTCHA script has loaded */
58
+ __recaptchaLoaded?: boolean;
59
+ /** Flag to track if reCAPTCHA script is currently loading */
60
+ __recaptchaLoading?: boolean;
61
+ /** Callbacks to execute when script finishes loading */
62
+ __recaptchaCallbacks?: Array<{
63
+ onLoad: () => void;
64
+ onError: (error: Error) => void;
65
+ }>;
33
66
  }
34
67
  }
35
68
 
@@ -42,6 +75,7 @@ declare global {
42
75
  * - Refreshes token periodically (tokens expire after 2 minutes)
43
76
  * - Stores token in hidden input field for form submission
44
77
  * - Graceful fallback when not configured
78
+ * - Lazy loading support to defer script loading until visible
45
79
  *
46
80
  * @example Basic usage
47
81
  * ```tsx
@@ -63,13 +97,32 @@ declare global {
63
97
  *
64
98
  * @example With callbacks
65
99
  * ```tsx
100
+ * // IMPORTANT: Memoize callbacks to prevent unnecessary re-renders
101
+ * const handleToken = useCallback((token: string) => {
102
+ * console.log("Token:", token);
103
+ * }, []);
104
+ *
105
+ * const handleError = useCallback((error: Error) => {
106
+ * console.error("Error:", error);
107
+ * }, []);
108
+ *
66
109
  * <RecaptchaWrapper
67
110
  * action="payment"
68
- * onTokenGenerated={(token) => console.log("Token:", token)}
69
- * onError={(error) => console.error("Error:", error)}
111
+ * onTokenGenerated={handleToken}
112
+ * onError={handleError}
70
113
  * />
71
114
  * ```
115
+ *
116
+ * @example Lazy loading for better performance
117
+ * ```tsx
118
+ * <RecaptchaWrapper action="contact_form" lazy />
119
+ * ```
120
+ *
121
+ * @example Lazy loading with custom root margin
122
+ * ```tsx
123
+ * <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="400px" />
124
+ * ```
72
125
  */
73
- declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
126
+ declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, lazy, lazyRootMargin, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
74
127
 
75
128
  export { RecaptchaWrapper, RecaptchaWrapper as default };