Skip to content

Commit dd1c7e9

Browse files
authored
Merge branch 'main' into chris/mobile-405-react-native-components-release
2 parents 0962a90 + 79d0ecf commit dd1c7e9

6 files changed

Lines changed: 239 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

.changeset/flat-pets-taste.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/localizations': minor
3+
---
4+
5+
Added en-XA locale to highlight unlocalized strings.

integration/tests/cache-components.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,5 +323,61 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern:
323323
const userId = await userIdElement.textContent();
324324
expect(userId).toMatch(/^user_/);
325325
});
326+
327+
test('sign out completes and navigation promise resolves', async ({ page, context }) => {
328+
const u = createTestUtils({ app, page, context });
329+
330+
// Sign in
331+
await u.po.signIn.goTo();
332+
await u.po.signIn.signInWithEmailAndInstantPassword({
333+
email: fakeUser.email,
334+
password: fakeUser.password,
335+
});
336+
await u.po.expect.toBeSignedIn();
337+
338+
// Navigate to a non-root page to ensure post-sign-out navigation is a real route change
339+
await u.page.goToRelative('/auth-server-component');
340+
await expect(u.page.getByText('auth() in Server Component')).toBeVisible();
341+
342+
// Sign out by explicitly awaiting the full signOut() promise.
343+
// Internally, signOut() calls: onBeforeSetActive (cache invalidation) →
344+
// session removal → navigate(redirectUrl) via routerPush → useInternalNavFun →
345+
// startTransition(() => router.push(to)).
346+
// The navigate() call awaits the promise from useInternalNavFun.
347+
// If isPending doesn't cycle (the concern from removing usePathname in #7989),
348+
// the navigation promise hangs and this evaluate call times out.
349+
await page.evaluate(async () => {
350+
await window.Clerk.signOut();
351+
});
352+
353+
await u.po.expect.toBeSignedOut();
354+
});
355+
356+
test('protected route redirects to sign-in after sign out', async ({ page, context }) => {
357+
const u = createTestUtils({ app, page, context });
358+
359+
// Sign in and access protected route
360+
await u.po.signIn.goTo();
361+
await u.po.signIn.signInWithEmailAndInstantPassword({
362+
email: fakeUser.email,
363+
password: fakeUser.password,
364+
});
365+
await u.po.expect.toBeSignedIn();
366+
367+
await u.page.goToRelative('/protected');
368+
await expect(u.page.getByText('Protected Route')).toBeVisible();
369+
370+
// Sign out
371+
await page.evaluate(async () => {
372+
await window.Clerk.signOut();
373+
});
374+
375+
await u.po.expect.toBeSignedOut();
376+
377+
// Try to access protected route again — should redirect to sign-in
378+
// This verifies cache invalidation worked correctly alongside navigation
379+
await u.page.goToRelative('/protected');
380+
await expect(page).toHaveURL(/sign-in/);
381+
});
326382
},
327383
);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { LocalizationResource } from '@clerk/shared/types';
2+
3+
import { enUS } from './en-US';
4+
5+
const pseudoCharacterMap = {
6+
a: 'å',
7+
b: 'ƀ',
8+
c: 'ç',
9+
d: 'ð',
10+
e: 'é',
11+
f: 'ƒ',
12+
g: 'ğ',
13+
h: 'ħ',
14+
i: 'ï',
15+
j: 'ĵ',
16+
k: 'ķ',
17+
l: 'ľ',
18+
m: 'ɱ',
19+
n: 'ñ',
20+
o: 'ø',
21+
p: 'þ',
22+
q: 'ʠ',
23+
r: 'ř',
24+
s: 'š',
25+
t: 'ŧ',
26+
u: 'ü',
27+
v: 'ṽ',
28+
w: 'ŵ',
29+
x: 'ẋ',
30+
y: 'ÿ',
31+
z: 'ž',
32+
} as const;
33+
34+
const pseudoCharacterMapWithUppercase = Object.fromEntries(
35+
Object.entries(pseudoCharacterMap).flatMap(([source, target]) => [
36+
[source, target],
37+
[source.toUpperCase(), target.toLocaleUpperCase('en-US')],
38+
]),
39+
) as Record<string, string>;
40+
41+
const tokenOrLetterPattern = /\{\{[^{}]*\}\}|\{[^{}]*\}|[a-zA-Z]/g;
42+
43+
function pseudoLocalizeString(value: string): string {
44+
return value.replace(tokenOrLetterPattern, segment => {
45+
if (segment.startsWith('{')) {
46+
return segment;
47+
}
48+
49+
return pseudoCharacterMapWithUppercase[segment] ?? segment;
50+
});
51+
}
52+
53+
function pseudoLocalizeValue<T>(value: T): T {
54+
if (typeof value === 'string') {
55+
return pseudoLocalizeString(value) as T;
56+
}
57+
58+
if (Array.isArray(value)) {
59+
return value.map(item => pseudoLocalizeValue(item)) as T;
60+
}
61+
62+
if (value && typeof value === 'object') {
63+
const localized: Record<string, unknown> = {};
64+
65+
for (const [key, nestedValue] of Object.entries(value)) {
66+
localized[key] = pseudoLocalizeValue(nestedValue);
67+
}
68+
69+
return localized as T;
70+
}
71+
72+
return value;
73+
}
74+
75+
const enXAFromEnUS = pseudoLocalizeValue(enUS);
76+
77+
export const enXA: LocalizationResource = {
78+
...enXAFromEnUS,
79+
locale: 'en-XA',
80+
};

packages/localizations/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { deDE } from './de-DE';
99
export { elGR } from './el-GR';
1010
export { enUS } from './en-US';
1111
export { enGB } from './en-GB';
12+
export { enXA } from './en-XA';
1213
export { esCR } from './es-CR';
1314
export { esES } from './es-ES';
1415
export { esMX } from './es-MX';

packages/nextjs/src/app-router/client/__tests__/useInternalNavFun.test.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,99 @@ describe('useInternalNavFun', () => {
9999
expect(routerNav).toHaveBeenCalledTimes(2);
100100
});
101101
});
102+
103+
it('resolves a single navigation promise without usePathname', async () => {
104+
const routerNav = vi.fn();
105+
render(<Harness routerNav={routerNav} />);
106+
107+
let promise!: Promise<void>;
108+
act(() => {
109+
promise = navigate('/dashboard');
110+
});
111+
112+
await waitFor(() => {
113+
expect(routerNav).toHaveBeenCalledWith('/dashboard');
114+
});
115+
116+
// Promise resolves via isPending cycling alone — no usePathname needed
117+
await expect(promise).resolves.toBeUndefined();
118+
});
119+
120+
it('flushes pre-existing window buffer on mount', async () => {
121+
const routerNav = vi.fn();
122+
123+
// Simulate promises left in the window buffer from a previous component instance
124+
// (e.g., ClerkProvider unmounted during navigation — the core scenario from #2899)
125+
let resolved1 = false;
126+
let resolved2 = false;
127+
window.__clerk_internal_navigations = {
128+
push: {
129+
promisesBuffer: [
130+
() => {
131+
resolved1 = true;
132+
},
133+
() => {
134+
resolved2 = true;
135+
},
136+
],
137+
},
138+
} as unknown as typeof window.__clerk_internal_navigations;
139+
140+
render(<Harness routerNav={routerNav} />);
141+
142+
// The mount effect should flush the pre-existing buffer
143+
await waitFor(() => {
144+
expect(resolved1).toBe(true);
145+
expect(resolved2).toBe(true);
146+
});
147+
});
148+
149+
it('flushes pending promises on unmount', async () => {
150+
const routerNav = vi.fn();
151+
const { unmount } = render(<Harness routerNav={routerNav} />);
152+
153+
// Manually add resolvers to the buffer to simulate pending navigations
154+
let resolved = false;
155+
const nav = window.__clerk_internal_navigations.push;
156+
nav.promisesBuffer = [
157+
() => {
158+
resolved = true;
159+
},
160+
];
161+
162+
unmount();
163+
164+
// The useEffect cleanup should have flushed the promise buffer
165+
expect(resolved).toBe(true);
166+
});
167+
168+
it('uses history pushState for internal navigations', async () => {
169+
const routerNav = vi.fn();
170+
const mockWindowNav = vi.fn();
171+
172+
let internalNavigate: NavigationFunction | undefined;
173+
const InternalHarness = () => {
174+
internalNavigate = useInternalNavFun({
175+
windowNav: mockWindowNav as typeof window.history.pushState,
176+
routerNav: routerNav as any,
177+
name: 'push',
178+
});
179+
return null;
180+
};
181+
182+
render(<InternalHarness />);
183+
184+
act(() => {
185+
internalNavigate!('/shallow-path', {
186+
__internal_metadata: { navigationType: 'internal' },
187+
windowNavigate: vi.fn(),
188+
});
189+
});
190+
191+
await waitFor(() => {
192+
expect(mockWindowNav).toHaveBeenCalledWith(null, '', '/shallow-path');
193+
});
194+
195+
expect(routerNav).not.toHaveBeenCalled();
196+
});
102197
});

0 commit comments

Comments
 (0)