From 138a17085c0fa7c3a92994d81958ee4db6bad899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Tue, 15 Sep 2026 12:42:19 +0200 Subject: [PATCH 1/2] refactor(shared): centralize url template parsing Moves populateParamFromObject and createDynamicParamParser from ui to shared to break a Mosaic->ui dependency. --- .changeset/url-template-parser.md | 6 ++++ packages/shared/src/__tests__/url.spec.ts | 27 ++++++++++++++ packages/shared/src/url.ts | 17 +++++++++ packages/ui/src/contexts/utils.ts | 4 +-- .../user-button/user-button.model.tsx | 36 +++++++++---------- .../__tests__/dynamicParamParser.test.ts | 29 --------------- packages/ui/src/utils/dynamicParamParser.ts | 14 -------- 7 files changed, 69 insertions(+), 64 deletions(-) create mode 100644 .changeset/url-template-parser.md delete mode 100644 packages/ui/src/utils/__tests__/dynamicParamParser.test.ts delete mode 100644 packages/ui/src/utils/dynamicParamParser.ts diff --git a/.changeset/url-template-parser.md b/.changeset/url-template-parser.md new file mode 100644 index 00000000000..cbdbc4fcce7 --- /dev/null +++ b/.changeset/url-template-parser.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': minor +'@clerk/ui': patch +--- + +Add `createDynamicParamParser` and `populateParamFromObject` to `@clerk/shared/url` for resolving `:property` placeholders in URL templates. diff --git a/packages/shared/src/__tests__/url.spec.ts b/packages/shared/src/__tests__/url.spec.ts index 4fcf79b3d0a..09f86354235 100644 --- a/packages/shared/src/__tests__/url.spec.ts +++ b/packages/shared/src/__tests__/url.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, it, test } from 'vitest'; import { addClerkPrefix, cleanDoubleSlashes, + createDynamicParamParser, getClerkJsMajorVersionOrTag, getScriptUrl, joinURL, @@ -252,3 +253,29 @@ describe('cleanDoubleSlashes', () => { expect(cleanDoubleSlashes()).toBe(''); }); }); + +describe('createDynamicParamParser', () => { + const entity = { + foo: 'foo_string', + bar: 'bar_string', + }; + + const testCases = [ + [':foo', entity, 'foo_string'], + ['/:foo', entity, '/foo_string'], + ['/some/:bar/any', entity, '/some/bar_string/any'], + ['/:notValid', entity, '/:notValid'], + ] as const; + + it.each(testCases)( + 'replaces the dynamic param with the value assigned to the key inside the object. Url=(%s), Object=(%s), result=(%s)', + (urlWithParam, obj, result) => { + expect( + createDynamicParamParser({ regex: /:(\w+)/ })({ + urlWithParam, + entity: obj, + }), + ).toEqual(result); + }, + ); +}); diff --git a/packages/shared/src/url.ts b/packages/shared/src/url.ts index bef7055749d..0097b467654 100644 --- a/packages/shared/src/url.ts +++ b/packages/shared/src/url.ts @@ -219,3 +219,20 @@ export function joinURL(base: string, ...input: string[]): string { // Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3 const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/; export const isAbsoluteUrl = (url: string) => ABSOLUTE_URL_REGEX.test(url); + +export const createDynamicParamParser = + ({ regex }: { regex: RegExp }) => + >({ urlWithParam, entity }: { urlWithParam: string; entity: T }) => { + const match = regex.exec(urlWithParam); + + if (match) { + const key = match[1]; + if (key in entity) { + const value = entity[key] as string; + return urlWithParam.replace(match[0], value); + } + } + return urlWithParam; + }; + +export const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ }); diff --git a/packages/ui/src/contexts/utils.ts b/packages/ui/src/contexts/utils.ts index 260c8965dbe..7b279b2b159 100644 --- a/packages/ui/src/contexts/utils.ts +++ b/packages/ui/src/contexts/utils.ts @@ -1,7 +1,7 @@ import { clerkCoreErrorContextProviderNotFound } from '@clerk/shared/internal/clerk-js/errors'; import { snakeToCamel } from '@clerk/shared/underscore'; -import { createDynamicParamParser } from '../utils/dynamicParamParser'; +export { populateParamFromObject } from '@clerk/shared/url'; export function assertContextExists(contextVal: unknown, providerName: string): asserts contextVal { if (!contextVal) { @@ -20,5 +20,3 @@ export function getInitialValuesFromQueryParams(queryString: string, params: str return props; } - -export const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ }); diff --git a/packages/ui/src/mosaic/features/user-button/user-button.model.tsx b/packages/ui/src/mosaic/features/user-button/user-button.model.tsx index 603ec84afa6..8345c2c4ddb 100644 --- a/packages/ui/src/mosaic/features/user-button/user-button.model.tsx +++ b/packages/ui/src/mosaic/features/user-button/user-button.model.tsx @@ -7,10 +7,10 @@ import type { OrganizationSwitcherProps, UserButtonProps as ClerkUserButtonProps, UserProfileModalProps, - UserResource, + UserResource } from '@clerk/shared/types'; +import { populateParamFromObject } from '@clerk/shared/url'; -import { populateParamFromObject } from '../../../contexts/utils'; import { useOrganizationListInView } from '../../../hooks/useOrganizationListInView'; import { useMosaicEnvironment } from '../../hooks/useMosaicEnvironment'; import { useMosaicRouter } from '../../hooks/useMosaicRouter'; @@ -38,13 +38,13 @@ export type UserButtonModel = | { status: 'loading' } | { status: 'hidden' } | (UserButtonData & - Omit & - UserButtonAsyncCallbacks & - UserButtonBrandingProps & { - status: 'ready'; - /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ - organizationsEnabled: boolean; - }); + Omit & + UserButtonAsyncCallbacks & + UserButtonBrandingProps & { + status: 'ready'; + /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ + organizationsEnabled: boolean; + }); /** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */ type UserProfileMode = @@ -227,15 +227,15 @@ export function useUserButtonModel(options?: UserButtonModelOptions, modals?: Us const invitations: UserButtonInvitation[] = invitationData.flatMap(i => i.status === 'pending' || i.status === 'accepted' ? [ - { - kind: 'invitation', - id: i.id, - status: i.status, - organizationId: i.publicOrganizationData.id, - organizationName: i.publicOrganizationData.name, - imageUrl: i.publicOrganizationData.imageUrl || undefined, - }, - ] + { + kind: 'invitation', + id: i.id, + status: i.status, + organizationId: i.publicOrganizationData.id, + organizationName: i.publicOrganizationData.name, + imageUrl: i.publicOrganizationData.imageUrl || undefined, + }, + ] : [], ); diff --git a/packages/ui/src/utils/__tests__/dynamicParamParser.test.ts b/packages/ui/src/utils/__tests__/dynamicParamParser.test.ts deleted file mode 100644 index 712de7756a4..00000000000 --- a/packages/ui/src/utils/__tests__/dynamicParamParser.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createDynamicParamParser } from '../dynamicParamParser'; - -const entity = { - foo: 'foo_string', - bar: 'bar_string', -}; - -describe('createDynamicParamParser', () => { - const testCases = [ - [':foo', entity, 'foo_string'], - ['/:foo', entity, '/foo_string'], - ['/some/:bar/any', entity, '/some/bar_string/any'], - ['/:notValid', entity, '/:notValid'], - ] as const; - - it.each(testCases)( - 'replaces the dynamic param with the value assigned to the key inside the object. Url=(%s), Object=(%s), result=(%s)', - (urlWithParam, obj, result) => { - expect( - createDynamicParamParser({ regex: /:(\w+)/ })({ - urlWithParam, - entity: obj, - }), - ).toEqual(result); - }, - ); -}); diff --git a/packages/ui/src/utils/dynamicParamParser.ts b/packages/ui/src/utils/dynamicParamParser.ts deleted file mode 100644 index 9f254312d9e..00000000000 --- a/packages/ui/src/utils/dynamicParamParser.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const createDynamicParamParser = - ({ regex }: { regex: RegExp }) => - >({ urlWithParam, entity }: { urlWithParam: string; entity: T }) => { - const match = regex.exec(urlWithParam); - - if (match) { - const key = match[1]; - if (key in entity) { - const value = entity[key] as string; - return urlWithParam.replace(match[0], value); - } - } - return urlWithParam; - }; From a7480ec24b916b1b9409b15ecf06f0919f7b1450 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Wed, 16 Sep 2026 11:46:13 +0200 Subject: [PATCH 2/2] Fix formatting --- .../user-button/user-button.model.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/mosaic/features/user-button/user-button.model.tsx b/packages/ui/src/mosaic/features/user-button/user-button.model.tsx index 8345c2c4ddb..6f878218ecf 100644 --- a/packages/ui/src/mosaic/features/user-button/user-button.model.tsx +++ b/packages/ui/src/mosaic/features/user-button/user-button.model.tsx @@ -7,7 +7,7 @@ import type { OrganizationSwitcherProps, UserButtonProps as ClerkUserButtonProps, UserProfileModalProps, - UserResource + UserResource, } from '@clerk/shared/types'; import { populateParamFromObject } from '@clerk/shared/url'; @@ -38,13 +38,13 @@ export type UserButtonModel = | { status: 'loading' } | { status: 'hidden' } | (UserButtonData & - Omit & - UserButtonAsyncCallbacks & - UserButtonBrandingProps & { - status: 'ready'; - /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ - organizationsEnabled: boolean; - }); + Omit & + UserButtonAsyncCallbacks & + UserButtonBrandingProps & { + status: 'ready'; + /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ + organizationsEnabled: boolean; + }); /** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */ type UserProfileMode = @@ -227,15 +227,15 @@ export function useUserButtonModel(options?: UserButtonModelOptions, modals?: Us const invitations: UserButtonInvitation[] = invitationData.flatMap(i => i.status === 'pending' || i.status === 'accepted' ? [ - { - kind: 'invitation', - id: i.id, - status: i.status, - organizationId: i.publicOrganizationData.id, - organizationName: i.publicOrganizationData.name, - imageUrl: i.publicOrganizationData.imageUrl || undefined, - }, - ] + { + kind: 'invitation', + id: i.id, + status: i.status, + organizationId: i.publicOrganizationData.id, + organizationName: i.publicOrganizationData.name, + imageUrl: i.publicOrganizationData.imageUrl || undefined, + }, + ] : [], );