Skip to content

Commit 49730d9

Browse files
authored
feature: improve error handling for @formspree/core (#47)
1 parent 4c40e1b commit 49730d9

29 files changed

+1599
-1151
lines changed

.changeset/orange-otters-teach.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@formspree/core': major
3+
'@formspree/react': minor
4+
---
5+
6+
## Improve error handling
7+
8+
- `@formspree/core` `submitForm` function now will never rejects but always produces a type of `SubmissionResult`, different types of the result can be refined/narrowed down using the field `kind`.
9+
- Provide `SubmissionErrorResult` which can be used to get an array of form errors and/or field errors (by field name)
10+
- `Response` is no longer made available on the submission result
11+
- Update `@formspree/react` for the changes introduced to `@formspree/core`

examples/cra-demo/package.json

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,12 @@
22
"name": "@formspree/cra-demo",
33
"version": "0.1.0",
44
"private": true,
5-
"dependencies": {
6-
"@formspree/react": "*",
7-
"react": "^18.2.0",
8-
"react-copy-to-clipboard": "^5.1.0",
9-
"react-dom": "^18.2.0",
10-
"react-google-recaptcha-v3": "^1.10.1",
11-
"react-scripts": "5.0.1",
12-
"web-vitals": "^2.1.4"
13-
},
145
"scripts": {
15-
"dev": "react-scripts start",
16-
"start": "react-scripts start",
176
"build": "react-scripts build",
7+
"clean": "rm -rf build && rm -rf node_modules",
8+
"dev": "react-scripts start",
189
"eject": "react-scripts eject",
19-
"clean": "rm -rf build && rm -rf node_modules"
20-
},
21-
"eslintConfig": {
22-
"extends": [
23-
"react-app",
24-
"react-app/jest"
25-
]
10+
"start": "react-scripts start"
2611
},
2712
"browserslist": {
2813
"production": [
@@ -36,15 +21,25 @@
3621
"last 1 safari version"
3722
]
3823
},
24+
"eslintConfig": {
25+
"extends": [
26+
"react-app",
27+
"react-app/jest"
28+
]
29+
},
30+
"dependencies": {
31+
"@formspree/react": "*",
32+
"react": "^18.2.0",
33+
"react-copy-to-clipboard": "^5.1.0",
34+
"react-dom": "^18.2.0",
35+
"react-google-recaptcha-v3": "^1.10.1",
36+
"react-scripts": "5.0.1",
37+
"web-vitals": "^2.1.4"
38+
},
3939
"devDependencies": {
4040
"@babel/core": "^7.22.1",
4141
"@babel/plugin-syntax-flow": "^7.21.4",
4242
"@babel/plugin-transform-react-jsx": "^7.22.0",
43-
"@testing-library/dom": "^9.3.0",
44-
"@testing-library/jest-dom": "^5.16.4",
45-
"@testing-library/react": "^13.3.0",
46-
"@testing-library/user-event": "^13.5.0",
47-
"@types/jest": "^27.5.2",
4843
"@types/node": "^16.11.42",
4944
"@types/react": "^18.0.14",
5045
"@types/react-copy-to-clipboard": "^5.0.3",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"eslint-config-prettier": "^8.8.0",
3232
"eslint-plugin-jest": "^27.2.2",
3333
"husky": "^8.0.0",
34+
"isomorphic-fetch": "^3.0.0",
3435
"jest": "^29.5.0",
3536
"jest-environment-jsdom": "^29.5.0",
3637
"lint-staged": "^13.2.2",

packages/formspree-core/.eslintrc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ root: true
22
extends:
33
- '../../.eslintrc.yml'
44
ignorePatterns:
5+
- jest.config.js
6+
- jest.setup.js
57
- dist/
68
parserOptions:
79
project: './tsconfig.json'

packages/formspree-core/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @type {import('jest').Config} */
22
const config = {
3+
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
34
testEnvironment: 'jsdom',
45
};
56

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Fix: ReferenceError: Response is not defined
2+
import 'isomorphic-fetch';

packages/formspree-core/src/core.ts

Lines changed: 126 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import type { Stripe } from '@stripe/stripe-js';
2-
import type {
3-
SubmissionData,
4-
SubmissionOptions,
5-
SubmissionBody,
6-
SubmissionResponse,
7-
} from './forms';
2+
import { Session } from './session';
3+
import {
4+
SubmissionError,
5+
SubmissionSuccess,
6+
StripeSCAPending,
7+
isServerErrorResponse,
8+
isServerSuccessResponse,
9+
isServerStripeSCAPendingResponse,
10+
type FieldValues,
11+
type SubmissionData,
12+
type SubmissionOptions,
13+
type SubmissionResult,
14+
} from './submission';
815
import {
916
appendExtraData,
1017
clientHeader,
1118
encode64,
12-
handleLegacyErrorPayload,
13-
handleSCA,
19+
isUnknownObject,
1420
} from './utils';
15-
import { Session } from './session';
1621

1722
export interface Config {
1823
project?: string;
@@ -22,19 +27,13 @@ export interface Config {
2227
export class Client {
2328
project: string | undefined;
2429
stripePromise: Stripe | undefined;
25-
private session: Session | undefined;
30+
private readonly session?: Session;
2631

2732
constructor(config: Config = {}) {
2833
this.project = config.project;
2934
this.stripePromise = config.stripePromise;
30-
if (typeof window !== 'undefined') this.startBrowserSession();
31-
}
3235

33-
/**
34-
* Starts a browser session.
35-
*/
36-
startBrowserSession(): void {
37-
if (!this.session) {
36+
if (typeof window !== 'undefined') {
3837
this.session = new Session();
3938
}
4039
}
@@ -46,22 +45,16 @@ export class Client {
4645
* @param data - An object or FormData instance containing submission data.
4746
* @param args - An object of form submission data.
4847
*/
49-
async submitForm(
48+
async submitForm<T extends FieldValues>(
5049
formKey: string,
51-
data: SubmissionData,
50+
data: SubmissionData<T>,
5251
opts: SubmissionOptions = {}
53-
): Promise<SubmissionResponse> {
52+
): Promise<SubmissionResult<T>> {
5453
const endpoint = opts.endpoint || 'https://formspree.io';
55-
const fetchImpl = opts.fetchImpl || fetch;
5654
const url = this.project
5755
? `${endpoint}/p/${this.project}/f/${formKey}`
5856
: `${endpoint}/f/${formKey}`;
5957

60-
const serializeBody = (data: SubmissionData): FormData | string => {
61-
if (data instanceof FormData) return data;
62-
return JSON.stringify(data);
63-
};
64-
6558
const headers: { [key: string]: string } = {
6659
Accept: 'application/json',
6760
'Formspree-Client': clientHeader(opts.clientName),
@@ -75,78 +68,123 @@ export class Client {
7568
headers['Content-Type'] = 'application/json';
7669
}
7770

78-
const request = {
79-
method: 'POST',
80-
mode: 'cors' as const,
81-
body: serializeBody(data),
82-
headers,
83-
};
71+
async function makeFormspreeRequest(
72+
data: SubmissionData<T>
73+
): Promise<SubmissionResult<T> | StripeSCAPending> {
74+
try {
75+
const res = await fetch(url, {
76+
method: 'POST',
77+
mode: 'cors',
78+
body: data instanceof FormData ? data : JSON.stringify(data),
79+
headers,
80+
});
81+
82+
const body = await res.json();
83+
84+
if (isUnknownObject(body)) {
85+
if (isServerErrorResponse(body)) {
86+
return Array.isArray(body.errors)
87+
? new SubmissionError(...body.errors)
88+
: new SubmissionError({ message: body.error });
89+
}
90+
91+
if (isServerStripeSCAPendingResponse(body)) {
92+
return new StripeSCAPending(
93+
body.stripe.paymentIntentClientSecret,
94+
body.resubmitKey
95+
);
96+
}
97+
98+
if (isServerSuccessResponse(body)) {
99+
return new SubmissionSuccess({ next: body.next });
100+
}
101+
}
102+
103+
return new SubmissionError({
104+
message: 'Unexpected response format',
105+
});
106+
} catch (err) {
107+
const message =
108+
err instanceof Error
109+
? err.message
110+
: `Unknown error while posting to Formspree: ${JSON.stringify(
111+
err
112+
)}`;
113+
return new SubmissionError({ message: message });
114+
}
115+
}
84116

85-
// first check if we need to add the stripe paymentMethod
86117
if (this.stripePromise && opts.createPaymentMethod) {
87-
// Get Stripe payload
88-
const payload = await opts.createPaymentMethod();
89-
90-
if (payload.error) {
91-
// Return the error in case Stripe failed to create a payment method
92-
return {
93-
response: null,
94-
body: {
95-
errors: [
96-
{
97-
code: 'STRIPE_CLIENT_ERROR',
98-
message: 'Error creating payment method',
99-
field: 'paymentMethod',
100-
},
101-
],
102-
},
103-
};
118+
const createPaymentMethodResult = await opts.createPaymentMethod();
119+
120+
if (createPaymentMethodResult.error) {
121+
return new SubmissionError({
122+
code: 'STRIPE_CLIENT_ERROR',
123+
field: 'paymentMethod',
124+
message: 'Error creating payment method',
125+
});
104126
}
105127

106128
// Add the paymentMethod to the data
107-
appendExtraData(data, 'paymentMethod', payload.paymentMethod.id);
129+
appendExtraData(
130+
data,
131+
'paymentMethod',
132+
createPaymentMethodResult.paymentMethod.id
133+
);
108134

109135
// Send a request to Formspree server to handle the payment method
110-
const response = await fetchImpl(url, {
111-
...request,
112-
body: serializeBody(data),
113-
});
114-
const responseData = await response.json();
115-
116-
// Handle SCA
117-
if (
118-
responseData &&
119-
responseData.stripe &&
120-
responseData.stripe.requiresAction &&
121-
responseData.resubmitKey
122-
) {
123-
return await handleSCA({
124-
stripePromise: this.stripePromise,
125-
responseData,
126-
response,
127-
payload,
128-
data,
129-
fetchImpl,
130-
request,
131-
url,
132-
});
136+
const result = await makeFormspreeRequest(data);
137+
138+
if (result.kind === 'error') {
139+
return result;
133140
}
134141

135-
return handleLegacyErrorPayload({
136-
response,
137-
body: responseData,
138-
});
139-
} else {
140-
return fetchImpl(url, request)
141-
.then((response) => {
142-
return response
143-
.json()
144-
.then((body: SubmissionBody): SubmissionResponse => {
145-
return handleLegacyErrorPayload({ body, response });
146-
});
147-
})
148-
.catch();
142+
if (result.kind === 'stripePluginPending') {
143+
const stripeResult = await this.stripePromise.handleCardAction(
144+
result.paymentIntentClientSecret
145+
);
146+
147+
if (stripeResult.error) {
148+
return new SubmissionError({
149+
code: 'STRIPE_CLIENT_ERROR',
150+
field: 'paymentMethod',
151+
message: 'Stripe SCA error',
152+
});
153+
}
154+
155+
// `paymentMethod` must not be on the payload when resubmitting
156+
// the form to handle Stripe SCA.
157+
if (data instanceof FormData) {
158+
data.delete('paymentMethod');
159+
} else {
160+
delete data.paymentMethod;
161+
}
162+
163+
appendExtraData(data, 'paymentIntent', stripeResult.paymentIntent.id);
164+
appendExtraData(data, 'resubmitKey', result.resubmitKey);
165+
166+
// Resubmit the form with the paymentIntent and resubmitKey
167+
const resubmitResult = await makeFormspreeRequest(data);
168+
assertSubmissionResult(resubmitResult);
169+
return resubmitResult;
170+
}
171+
172+
return result;
149173
}
174+
175+
const result = await makeFormspreeRequest(data);
176+
assertSubmissionResult(result);
177+
return result;
178+
}
179+
}
180+
181+
// assertSubmissionResult ensures the result is SubmissionResult
182+
function assertSubmissionResult<T extends FieldValues>(
183+
result: SubmissionResult<T> | StripeSCAPending
184+
): asserts result is SubmissionResult<T> {
185+
const { kind } = result;
186+
if (kind !== 'success' && kind !== 'error') {
187+
throw new Error(`Unexpected submission result (kind: ${kind})`);
150188
}
151189
}
152190

0 commit comments

Comments
 (0)