Skip to content

Commit 5ec7938

Browse files
evan-masseauclaude
andcommitted
fix(forms): throw on missing required fields in bridge message parsing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fa9f819 commit 5ec7938

3 files changed

Lines changed: 356 additions & 13 deletions

File tree

src/Forms.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,91 @@ export type FormLifecycleEvent =
7272
* Handler function type for form lifecycle events
7373
*/
7474
export type FormLifecycleHandler = (event: FormLifecycleEvent) => void;
75+
76+
/**
77+
* Valid form lifecycle event type discriminants
78+
*/
79+
const FORM_LIFECYCLE_EVENT_TYPES = [
80+
'formShown',
81+
'formDismissed',
82+
'formCtaClicked',
83+
] as const;
84+
85+
/**
86+
* Validates that a value is a non-empty string.
87+
*/
88+
function isNonEmptyString(value: unknown): value is string {
89+
return typeof value === 'string' && value.length > 0;
90+
}
91+
92+
/**
93+
* Parses a raw native event payload into a validated {@link FormLifecycleEvent}.
94+
*
95+
* Returns `null` and logs a warning if required fields are missing or empty.
96+
* Required fields vary by event type:
97+
* - All events: `type`, `formId`, `formName`
98+
* - `formCtaClicked`: additionally requires `buttonLabel`
99+
*
100+
* @param data Raw event data from the native bridge
101+
* @returns A validated FormLifecycleEvent, or null if the payload is invalid
102+
*/
103+
export function parseFormLifecycleEvent(
104+
data: Record<string, unknown>
105+
): FormLifecycleEvent | null {
106+
const { type, formId, formName } = data;
107+
108+
if (
109+
!isNonEmptyString(type) ||
110+
!FORM_LIFECYCLE_EVENT_TYPES.includes(
111+
type as (typeof FORM_LIFECYCLE_EVENT_TYPES)[number]
112+
)
113+
) {
114+
console.error(
115+
`[Klaviyo] Ignoring form lifecycle event with invalid type: ${JSON.stringify(type)}`
116+
);
117+
return null;
118+
}
119+
120+
const missingFields: string[] = [];
121+
if (!isNonEmptyString(formId)) missingFields.push('formId');
122+
if (!isNonEmptyString(formName)) missingFields.push('formName');
123+
124+
if (type === 'formCtaClicked' && !isNonEmptyString(data.buttonLabel)) {
125+
missingFields.push('buttonLabel');
126+
}
127+
128+
if (missingFields.length > 0) {
129+
console.error(
130+
`[Klaviyo] Ignoring ${type} event: missing required field(s): ${missingFields.join(', ')}`
131+
);
132+
return null;
133+
}
134+
135+
const validatedType = type as FormLifecycleEvent['type'];
136+
const validFormId = formId as string;
137+
const validFormName = formName as string;
138+
139+
switch (validatedType) {
140+
case 'formShown':
141+
return {
142+
type: validatedType,
143+
formId: validFormId,
144+
formName: validFormName,
145+
};
146+
case 'formDismissed':
147+
return {
148+
type: validatedType,
149+
formId: validFormId,
150+
formName: validFormName,
151+
};
152+
case 'formCtaClicked':
153+
return {
154+
type: validatedType,
155+
formId: validFormId,
156+
formName: validFormName,
157+
buttonLabel: data.buttonLabel as string,
158+
deepLinkUrl:
159+
typeof data.deepLinkUrl === 'string' ? data.deepLinkUrl : '',
160+
};
161+
}
162+
}

src/__tests__/Forms.test.ts

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { parseFormLifecycleEvent } from '../Forms';
2+
3+
describe('parseFormLifecycleEvent', () => {
4+
let consoleErrorSpy: jest.SpyInstance;
5+
6+
beforeEach(() => {
7+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
8+
});
9+
10+
afterEach(() => {
11+
consoleErrorSpy.mockRestore();
12+
});
13+
14+
describe('valid events', () => {
15+
it('parses a valid formShown event', () => {
16+
const result = parseFormLifecycleEvent({
17+
type: 'formShown',
18+
formId: 'abc123',
19+
formName: 'Welcome Form',
20+
});
21+
22+
expect(result).toEqual({
23+
type: 'formShown',
24+
formId: 'abc123',
25+
formName: 'Welcome Form',
26+
});
27+
expect(consoleErrorSpy).not.toHaveBeenCalled();
28+
});
29+
30+
it('parses a valid formDismissed event', () => {
31+
const result = parseFormLifecycleEvent({
32+
type: 'formDismissed',
33+
formId: 'abc123',
34+
formName: 'Welcome Form',
35+
});
36+
37+
expect(result).toEqual({
38+
type: 'formDismissed',
39+
formId: 'abc123',
40+
formName: 'Welcome Form',
41+
});
42+
expect(consoleErrorSpy).not.toHaveBeenCalled();
43+
});
44+
45+
it('parses a valid formCtaClicked event with all fields', () => {
46+
const result = parseFormLifecycleEvent({
47+
type: 'formCtaClicked',
48+
formId: 'abc123',
49+
formName: 'Welcome Form',
50+
buttonLabel: 'Shop Now',
51+
deepLinkUrl: 'myapp://products',
52+
});
53+
54+
expect(result).toEqual({
55+
type: 'formCtaClicked',
56+
formId: 'abc123',
57+
formName: 'Welcome Form',
58+
buttonLabel: 'Shop Now',
59+
deepLinkUrl: 'myapp://products',
60+
});
61+
expect(consoleErrorSpy).not.toHaveBeenCalled();
62+
});
63+
64+
it('defaults deepLinkUrl to empty string when absent', () => {
65+
const result = parseFormLifecycleEvent({
66+
type: 'formCtaClicked',
67+
formId: 'abc123',
68+
formName: 'Welcome Form',
69+
buttonLabel: 'Shop Now',
70+
});
71+
72+
expect(result).toEqual({
73+
type: 'formCtaClicked',
74+
formId: 'abc123',
75+
formName: 'Welcome Form',
76+
buttonLabel: 'Shop Now',
77+
deepLinkUrl: '',
78+
});
79+
});
80+
81+
it('strips extra fields not part of the event type', () => {
82+
const result = parseFormLifecycleEvent({
83+
type: 'formShown',
84+
formId: 'abc123',
85+
formName: 'Welcome Form',
86+
unexpectedField: 'should be ignored',
87+
});
88+
89+
expect(result).toEqual({
90+
type: 'formShown',
91+
formId: 'abc123',
92+
formName: 'Welcome Form',
93+
});
94+
});
95+
});
96+
97+
describe('invalid type', () => {
98+
it('returns null for unknown type', () => {
99+
const result = parseFormLifecycleEvent({
100+
type: 'formExploded',
101+
formId: 'abc123',
102+
formName: 'Welcome Form',
103+
});
104+
105+
expect(result).toBeNull();
106+
expect(consoleErrorSpy).toHaveBeenCalledWith(
107+
expect.stringContaining('invalid type: "formExploded"')
108+
);
109+
});
110+
111+
it('returns null for missing type', () => {
112+
const result = parseFormLifecycleEvent({
113+
formId: 'abc123',
114+
formName: 'Welcome Form',
115+
});
116+
117+
expect(result).toBeNull();
118+
expect(consoleErrorSpy).toHaveBeenCalledWith(
119+
expect.stringContaining('invalid type')
120+
);
121+
});
122+
123+
it('returns null for empty string type', () => {
124+
const result = parseFormLifecycleEvent({
125+
type: '',
126+
formId: 'abc123',
127+
formName: 'Welcome Form',
128+
});
129+
130+
expect(result).toBeNull();
131+
expect(consoleErrorSpy).toHaveBeenCalledWith(
132+
expect.stringContaining('invalid type')
133+
);
134+
});
135+
136+
it('returns null for non-string type', () => {
137+
const result = parseFormLifecycleEvent({
138+
type: 42,
139+
formId: 'abc123',
140+
formName: 'Welcome Form',
141+
});
142+
143+
expect(result).toBeNull();
144+
expect(consoleErrorSpy).toHaveBeenCalledWith(
145+
expect.stringContaining('invalid type')
146+
);
147+
});
148+
});
149+
150+
describe('missing required fields', () => {
151+
it('returns null when formId is missing', () => {
152+
const result = parseFormLifecycleEvent({
153+
type: 'formShown',
154+
formName: 'Welcome Form',
155+
});
156+
157+
expect(result).toBeNull();
158+
expect(consoleErrorSpy).toHaveBeenCalledWith(
159+
expect.stringContaining('missing required field(s): formId')
160+
);
161+
});
162+
163+
it('returns null when formName is missing', () => {
164+
const result = parseFormLifecycleEvent({
165+
type: 'formShown',
166+
formId: 'abc123',
167+
});
168+
169+
expect(result).toBeNull();
170+
expect(consoleErrorSpy).toHaveBeenCalledWith(
171+
expect.stringContaining('missing required field(s): formName')
172+
);
173+
});
174+
175+
it('returns null when formId is empty string', () => {
176+
const result = parseFormLifecycleEvent({
177+
type: 'formDismissed',
178+
formId: '',
179+
formName: 'Welcome Form',
180+
});
181+
182+
expect(result).toBeNull();
183+
expect(consoleErrorSpy).toHaveBeenCalledWith(
184+
expect.stringContaining('formId')
185+
);
186+
});
187+
188+
it('returns null when formName is empty string', () => {
189+
const result = parseFormLifecycleEvent({
190+
type: 'formDismissed',
191+
formId: 'abc123',
192+
formName: '',
193+
});
194+
195+
expect(result).toBeNull();
196+
expect(consoleErrorSpy).toHaveBeenCalledWith(
197+
expect.stringContaining('formName')
198+
);
199+
});
200+
201+
it('returns null when buttonLabel is missing for formCtaClicked', () => {
202+
const result = parseFormLifecycleEvent({
203+
type: 'formCtaClicked',
204+
formId: 'abc123',
205+
formName: 'Welcome Form',
206+
deepLinkUrl: 'myapp://products',
207+
});
208+
209+
expect(result).toBeNull();
210+
expect(consoleErrorSpy).toHaveBeenCalledWith(
211+
expect.stringContaining('missing required field(s): buttonLabel')
212+
);
213+
});
214+
215+
it('returns null when buttonLabel is empty string for formCtaClicked', () => {
216+
const result = parseFormLifecycleEvent({
217+
type: 'formCtaClicked',
218+
formId: 'abc123',
219+
formName: 'Welcome Form',
220+
buttonLabel: '',
221+
deepLinkUrl: 'myapp://products',
222+
});
223+
224+
expect(result).toBeNull();
225+
expect(consoleErrorSpy).toHaveBeenCalledWith(
226+
expect.stringContaining('buttonLabel')
227+
);
228+
});
229+
230+
it('reports multiple missing fields together', () => {
231+
const result = parseFormLifecycleEvent({
232+
type: 'formCtaClicked',
233+
});
234+
235+
expect(result).toBeNull();
236+
expect(consoleErrorSpy).toHaveBeenCalledWith(
237+
expect.stringContaining('formId, formName, buttonLabel')
238+
);
239+
});
240+
241+
it('does not require buttonLabel for formShown', () => {
242+
const result = parseFormLifecycleEvent({
243+
type: 'formShown',
244+
formId: 'abc123',
245+
formName: 'Welcome Form',
246+
});
247+
248+
expect(result).not.toBeNull();
249+
});
250+
251+
it('does not require buttonLabel for formDismissed', () => {
252+
const result = parseFormLifecycleEvent({
253+
type: 'formDismissed',
254+
formId: 'abc123',
255+
formName: 'Welcome Form',
256+
});
257+
258+
expect(result).not.toBeNull();
259+
});
260+
});
261+
});

src/index.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,8 @@ import {
66
formatProfile,
77
} from './Profile';
88
import type { Event } from './Event';
9-
import type {
10-
FormConfiguration,
11-
FormLifecycleHandler,
12-
FormLifecycleEvent,
13-
} from './Forms';
9+
import type { FormConfiguration, FormLifecycleHandler } from './Forms';
10+
import { parseFormLifecycleEvent } from './Forms';
1411
import type { Geofence } from './Geofencing';
1512
import { NativeEventEmitter, NativeModules } from 'react-native';
1613

@@ -160,14 +157,11 @@ export const Klaviyo: KlaviyoInterface = {
160157

161158
activeLifecycleSubscription = eventEmitter.addListener(
162159
'FormLifecycleEvent',
163-
(data: {
164-
type: string;
165-
formId: string;
166-
formName: string;
167-
buttonLabel?: string;
168-
deepLinkUrl?: string;
169-
}) => {
170-
handler(data as FormLifecycleEvent);
160+
(data: Record<string, unknown>) => {
161+
const event = parseFormLifecycleEvent(data);
162+
if (event !== null) {
163+
handler(event);
164+
}
171165
}
172166
);
173167

0 commit comments

Comments
 (0)