Skip to content

Commit 352afca

Browse files
chore(clerk-js): Replace qs library with custom implementation (#3430)
Co-authored-by: Nikos Douvlis <[email protected]>
1 parent 35f0602 commit 352afca

File tree

11 files changed

+167
-35
lines changed

11 files changed

+167
-35
lines changed

.changeset/silent-countries-jump.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clerk/clerk-js": patch
3+
---
4+
5+
Remove the qs library and use the native URLSearchParams API instead.

package-lock.json

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/clerk-js/bundlewatch.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.browser.js", "maxSize": "72kB" },
4-
{ "path": "./dist/clerk.headless.js", "maxSize": "48kB" },
3+
{ "path": "./dist/clerk.browser.js", "maxSize": "62kB" },
4+
{ "path": "./dist/clerk.headless.js", "maxSize": "43kB" },
55
{ "path": "./dist/ui-common*.js", "maxSize": "85KB" },
66
{ "path": "./dist/vendors*.js", "maxSize": "70KB" },
77
{ "path": "./dist/createorganization*.js", "maxSize": "5KB" },

packages/clerk-js/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
"core-js": "3.26.1",
6565
"dequal": "2.0.3",
6666
"qrcode.react": "3.1.0",
67-
"qs": "6.11.0",
6867
"regenerator-runtime": "0.13.11"
6968
},
7069
"devDependencies": {
@@ -77,7 +76,6 @@
7776
"@clerk/eslint-config-custom": "*",
7877
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
7978
"@svgr/webpack": "^6.2.1",
80-
"@types/qs": "^6.9.3",
8179
"@types/react": "*",
8280
"@types/react-dom": "*",
8381
"@types/webpack-dev-server": "^4.7.2",

packages/clerk-js/src/core/__tests__/fapiClient.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,20 +90,45 @@ describe('buildUrl(options)', () => {
9090
);
9191
});
9292

93-
it('correctly parses search params', () => {
93+
it('parses search params is an object with string values', () => {
9494
expect(fapiClient.buildUrl({ path: '/foo', search: { test: '1' } }).href).toBe(
9595
'https://clerk.example.com/v1/foo?test=1&_clerk_js_version=42.0.0',
9696
);
97+
});
9798

99+
it('parses string search params ', () => {
98100
expect(fapiClient.buildUrl({ path: '/foo', search: 'test=2' }).href).toBe(
99101
'https://clerk.example.com/v1/foo?test=2&_clerk_js_version=42.0.0',
100102
);
103+
});
104+
105+
it('parses search params when value contains invalid url symbols', () => {
106+
expect(fapiClient.buildUrl({ path: '/foo', search: { bar: 'test=2' } }).href).toBe(
107+
'https://clerk.example.com/v1/foo?bar=test%3D2&_clerk_js_version=42.0.0',
108+
);
109+
});
110+
111+
it('parses search params when value is an array', () => {
112+
expect(
113+
fapiClient.buildUrl({
114+
path: '/foo',
115+
search: {
116+
array: ['item1', 'item2'],
117+
},
118+
}).href,
119+
).toBe('https://clerk.example.com/v1/foo?array=item1&array=item2&_clerk_js_version=42.0.0');
120+
});
101121

122+
// The return value isn't as expected.
123+
// The buildUrl function converts an undefined value to the string 'undefined'
124+
// and includes it in the search parameters.
125+
it.skip('parses search params when value is undefined', () => {
102126
expect(
103127
fapiClient.buildUrl({
104128
path: '/foo',
105129
search: {
106130
array: ['item1', 'item2'],
131+
test: undefined,
107132
},
108133
}).href,
109134
).toBe('https://clerk.example.com/v1/foo?array=item1&array=item2&_clerk_js_version=42.0.0');

packages/clerk-js/src/core/fapiClient.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { camelToSnake, isBrowserOnline, runWithExponentialBackOff } from '@clerk/shared';
22
import type { Clerk, ClerkAPIErrorJSON, ClientJSON } from '@clerk/types';
3-
import qs from 'qs';
43

5-
import { buildEmailAddress as buildEmailAddressUtil, buildURL as buildUrlUtil } from '../utils';
4+
import { buildEmailAddress as buildEmailAddressUtil, buildURL as buildUrlUtil, stringifyQueryParams } from '../utils';
65
import { clerkNetworkError } from './errors';
76

87
export type HTTPMethod = 'CONNECT' | 'DELETE' | 'GET' | 'HEAD' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT' | 'TRACE';
@@ -32,10 +31,6 @@ export type FapiRequestCallback<T> = (
3231
response?: FapiResponse<T>,
3332
) => Promise<unknown | false> | unknown | false;
3433

35-
const camelToSnakeEncoder: qs.IStringifyOptions['encoder'] = (str, defaultEncoder, _, type) => {
36-
return type === 'key' ? camelToSnake(str) : defaultEncoder(str);
37-
};
38-
3934
// TODO: Move to @clerk/types
4035
export interface FapiResponseJSON<T> {
4136
response: T;
@@ -125,7 +120,7 @@ export function createFapiClient(clerkInstance: Clerk): FapiClient {
125120
return acc;
126121
}, {} as FapiQueryStringParameters & Record<string, string | string[]>);
127122

128-
return qs.stringify(objParams, { addQueryPrefix: true, arrayFormat: 'repeat' });
123+
return stringifyQueryParams(objParams);
129124
}
130125

131126
function buildUrl(requestInit: FapiRequestInit): URL {
@@ -193,8 +188,14 @@ export function createFapiClient(clerkInstance: Clerk): FapiClient {
193188
// Currently, this is needed only for form-urlencoded, so that the values reach the server in the form
194189
// foo=bar&baz=bar&whatever=1
195190
// @ts-ignore
191+
196192
if (requestInit.headers.get('content-type') === 'application/x-www-form-urlencoded') {
197-
requestInit.body = qs.stringify(body, { encoder: camelToSnakeEncoder, indices: false });
193+
// The native BodyInit type is too wide for our use case,
194+
// so we're casting it to a more specific type here.
195+
// This is covered by the test suite.
196+
requestInit.body = body
197+
? stringifyQueryParams(body as any as Record<string, string>, { keyEncoder: camelToSnake })
198+
: body;
198199
}
199200

200201
const beforeRequestCallbacksResult = await runBeforeRequestCallbacks(requestInit);

packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { RedirectUrls } from '../../utils/redirectUrls';
99
import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID } from '../constants';
1010
import { useEnvironment, useOptions } from '../contexts';
1111
import type { NavbarRoute } from '../elements';
12-
import type { ParsedQs } from '../router';
12+
import type { ParsedQueryString } from '../router';
1313
import { useRouter } from '../router';
1414
import type {
1515
AvailableComponentCtx,
@@ -44,7 +44,7 @@ const getInitialValuesFromQueryParams = (queryString: string, params: string[])
4444

4545
export type SignUpContextType = SignUpCtx & {
4646
navigateAfterSignUp: () => any;
47-
queryParams: ParsedQs;
47+
queryParams: ParsedQueryString;
4848
signInUrl: string;
4949
signUpUrl: string;
5050
secondFactorUrl: string;
@@ -115,7 +115,7 @@ export const useSignUpContext = (): SignUpContextType => {
115115

116116
export type SignInContextType = SignInCtx & {
117117
navigateAfterSignIn: () => any;
118-
queryParams: ParsedQs;
118+
queryParams: ParsedQueryString;
119119
signUpUrl: string;
120120
signInUrl: string;
121121
signUpContinueUrl: string;
@@ -203,7 +203,7 @@ type PagesType = {
203203
};
204204

205205
export type UserProfileContextType = UserProfileCtx & {
206-
queryParams: ParsedQs;
206+
queryParams: ParsedQueryString;
207207
authQueryString: string | null;
208208
pages: PagesType;
209209
};

packages/clerk-js/src/ui/router/BaseRouter.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { useClerk } from '@clerk/shared/react';
22
import type { NavigateOptions } from '@clerk/types';
3-
import qs from 'qs';
43
import React from 'react';
54

6-
import { getQueryParams, trimTrailingSlash } from '../../utils';
5+
import { getQueryParams, stringifyQueryParams, trimTrailingSlash } from '../../utils';
76
import { useWindowEventListener } from '../hooks';
87
import { newPaths } from './newPaths';
98
import { match } from './pathToRegexp';
@@ -113,7 +112,8 @@ export const BaseRouter = ({
113112
toQueryParams[param] = currentQueryParams[param];
114113
}
115114
});
116-
toURL.search = qs.stringify(toQueryParams);
115+
116+
toURL.search = stringifyQueryParams(toQueryParams);
117117
}
118118
const internalNavRes = await internalNavigate(toURL, { metadata: { navigationType: 'internal' } });
119119
setRouteParts({ path: toURL.pathname, queryString: toURL.search });

packages/clerk-js/src/ui/router/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ export * from './VirtualRouter';
55
export * from './Route';
66
export * from './Switch';
77

8-
export type { ParsedQs } from 'qs';
8+
export type ParsedQueryString = Record<string, string>;
Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,75 @@
1+
import { camelToSnake } from '@clerk/shared';
2+
13
import { getQueryParams, stringifyQueryParams } from '../querystring';
24

35
describe('getQueryParams(string)', () => {
4-
it('parses a querystring', () => {
5-
expect(getQueryParams('')).toEqual({});
6-
expect(getQueryParams('foo=42&bar=43')).toEqual({ foo: '42', bar: '43' });
7-
expect(getQueryParams('?foo=42&bar=43')).toEqual({ foo: '42', bar: '43' });
6+
it('parses an emtpy querystring', () => {
7+
const res = getQueryParams('');
8+
expect(res).toEqual({});
9+
});
10+
11+
it('parses a querystring into a URLSearchParams instance', () => {
12+
const res = getQueryParams('foo=42&bar=43');
13+
expect(res).toEqual({ foo: '42', bar: '43' });
14+
});
15+
16+
it('parses a querystring into a URLSearchParams instance even when prefixed with ?', () => {
17+
const res = getQueryParams('?foo=42&bar=43');
18+
expect(res).toEqual({ foo: '42', bar: '43' });
19+
});
20+
21+
it('parses multiple occurances of the same key as an array', () => {
22+
const res = getQueryParams('?foo=42&foo=43&bar=1');
23+
expect(res).toEqual({ foo: ['42', '43'], bar: '1' });
824
});
925
});
1026

1127
describe('stringifyQueryParams(object)', () => {
28+
it('handles null values', () => {
29+
expect(stringifyQueryParams(null)).toBe('');
30+
});
31+
32+
it('handles undefined values', () => {
33+
expect(stringifyQueryParams(undefined)).toBe('');
34+
});
35+
36+
it('handles string values', () => {
37+
expect(stringifyQueryParams('hello')).toBe('');
38+
});
39+
40+
it('handles empty string values', () => {
41+
expect(stringifyQueryParams('')).toBe('');
42+
});
43+
1244
it('converts an object to querystring', () => {
13-
expect(stringifyQueryParams({})).toEqual('');
1445
expect(stringifyQueryParams({ foo: '42', bar: '43' })).toBe('foo=42&bar=43');
1546
});
47+
48+
it('converts an object to querystring when value is an array', () => {
49+
expect(stringifyQueryParams({ foo: ['42', '22'], bar: '43' })).toBe('foo=42&foo=22&bar=43');
50+
});
51+
52+
it('converts an object to querystring when value is undefined', () => {
53+
expect(stringifyQueryParams({ foo: ['42', '22'], bar: undefined })).toBe('foo=42&foo=22');
54+
});
55+
56+
it('converts an object to querystring when value is null', () => {
57+
expect(stringifyQueryParams({ foo: null })).toBe('foo=');
58+
});
59+
60+
it('converts an object to querystring when value is an object', () => {
61+
expect(stringifyQueryParams({ unsafe_metadata: { bar: '1' } })).toBe('unsafe_metadata=%7B%22bar%22%3A%221%22%7D');
62+
});
63+
64+
it('converts an object to querystring when value contains invalid url symbols', () => {
65+
expect(stringifyQueryParams({ test: 'ena=duo' })).toBe('test=ena%3Dduo');
66+
});
67+
68+
it('converts an object to querystring when key is camelCase', () => {
69+
expect(stringifyQueryParams({ barFoo: '1' }, { keyEncoder: camelToSnake })).toBe('bar_foo=1');
70+
expect(stringifyQueryParams({ unsafeMetadata: { bar: '1' } }, { keyEncoder: camelToSnake })).toBe(
71+
'unsafe_metadata=%7B%22bar%22%3A%221%22%7D',
72+
);
73+
});
1674
});
75+
//test=ena%3Dduo
Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,56 @@
1-
import qs from 'qs';
2-
31
export const getQueryParams = (queryString: string) => {
4-
return qs.parse(queryString || '', {
5-
ignoreQueryPrefix: true,
6-
}) as Record<string, string>;
2+
const queryParamsObject: { [key: string]: string | string[] } = {};
3+
const queryParams = new URLSearchParams(queryString);
4+
queryParams.forEach((value, key) => {
5+
if (key in queryParamsObject) {
6+
// If the key already exists, we need to handle it as an array
7+
const existingValue = queryParamsObject[key];
8+
if (Array.isArray(existingValue)) {
9+
existingValue.push(value);
10+
} else {
11+
queryParamsObject[key] = [existingValue, value];
12+
}
13+
} else {
14+
queryParamsObject[key] = value;
15+
}
16+
});
17+
return queryParamsObject as Record<string, string>;
18+
};
19+
20+
type StringifyQueryParamsOptions = {
21+
keyEncoder?: (key: string) => string;
722
};
823

9-
export const stringifyQueryParams = (params: Record<string, unknown> | Array<unknown>) => {
10-
return qs.stringify(params || {});
24+
export const stringifyQueryParams = (
25+
params:
26+
| Record<string, string | undefined | null | object | Array<string | undefined | null>>
27+
| null
28+
| undefined
29+
| string,
30+
opts: StringifyQueryParamsOptions = {},
31+
) => {
32+
if (params === null || params === undefined) {
33+
return '';
34+
}
35+
if (!params || typeof params !== 'object') {
36+
return '';
37+
}
38+
39+
const queryParams = new URLSearchParams();
40+
41+
Object.keys(params).forEach(key => {
42+
const encodedKey = opts.keyEncoder ? opts.keyEncoder(key) : key;
43+
const value = params[key];
44+
if (Array.isArray(value)) {
45+
value.forEach(v => v !== undefined && queryParams.append(encodedKey, v || ''));
46+
} else if (value === undefined) {
47+
return;
48+
} else if (typeof value === 'object' && value !== null) {
49+
queryParams.append(encodedKey, JSON.stringify(value));
50+
} else {
51+
queryParams.append(encodedKey, value || '');
52+
}
53+
});
54+
55+
return queryParams.toString();
1156
};

0 commit comments

Comments
 (0)