Skip to content

Commit bdc690d

Browse files
wmertensJerryWu1234
andcommitted
feat(core): use AsyncLocalStorage for locale
Co-Authored-By: Jerry_wu <[email protected]>
1 parent 7af2eff commit bdc690d

File tree

6 files changed

+167
-0
lines changed

6 files changed

+167
-0
lines changed

.changeset/pretty-parents-draw.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@qwik.dev/router': patch
3+
'@qwik.dev/core': patch
4+
---
5+
6+
FEAT: withLocale() uses AsyncLocalStorage for server-side requests when available. This allows async operations to retain the correct locale context.

packages/docs/src/repl/bundler/rollup-plugins.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export const replResolver = (
6363
if (id.startsWith('/qwik/')) {
6464
return id;
6565
}
66+
// Replace node: with modules that throw on import
67+
if (id.startsWith('node:')) {
68+
return id;
69+
}
6670
const match = id.match(/(@builder\.io\/qwik|@qwik\.dev\/core)(.*)/);
6771
if (match) {
6872
const pkgName = match[2];
@@ -114,6 +118,9 @@ export const replResolver = (
114118
if (input && typeof input.code === 'string') {
115119
return input.code;
116120
}
121+
if (id.startsWith('node:')) {
122+
return `throw new Error('Module "${id}" is not available in the REPL environment.');`;
123+
}
117124
if (id.startsWith('/qwik/')) {
118125
const path = id.slice('/qwik'.length);
119126
if (path === '/build') {

packages/qwik/src/core/use/use-locale.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
import { tryGetInvokeContext } from './use-core';
2+
import { isServer } from '@qwik.dev/core/build';
3+
import type { AsyncLocalStorage } from 'node:async_hooks';
24

35
let _locale: string | undefined = undefined;
46

7+
let localAsyncStore: AsyncLocalStorage<string> | undefined;
8+
9+
if (isServer) {
10+
import('node:async_hooks')
11+
.then((module) => {
12+
localAsyncStore = new module.AsyncLocalStorage();
13+
})
14+
.catch(() => {
15+
// ignore if AsyncLocalStorage is not available
16+
});
17+
}
18+
519
/**
620
* Retrieve the current locale.
721
*
@@ -11,6 +25,14 @@ let _locale: string | undefined = undefined;
1125
* @public
1226
*/
1327
export function getLocale(defaultLocale?: string): string {
28+
// Prefer per-request locale from local AsyncLocalStorage if available (server-side)
29+
if (localAsyncStore) {
30+
const locale = localAsyncStore.getStore();
31+
if (locale) {
32+
return locale;
33+
}
34+
}
35+
1436
if (_locale === undefined) {
1537
const ctx = tryGetInvokeContext();
1638
if (ctx && ctx.$locale$) {
@@ -30,6 +52,10 @@ export function getLocale(defaultLocale?: string): string {
3052
* @public
3153
*/
3254
export function withLocale<T>(locale: string, fn: () => T): T {
55+
if (localAsyncStore) {
56+
return localAsyncStore.run(locale, fn);
57+
}
58+
3359
const previousLang = _locale;
3460
try {
3561
_locale = locale;
@@ -48,5 +74,9 @@ export function withLocale<T>(locale: string, fn: () => T): T {
4874
* @public
4975
*/
5076
export function setLocale(locale: string): void {
77+
if (localAsyncStore) {
78+
localAsyncStore.enterWith(locale);
79+
return;
80+
}
5181
_locale = locale;
5282
}

packages/qwik/src/optimizer/src/plugins/vite.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export function qwikVite(qwikViteOpts: QwikVitePluginOptions = {}): any {
265265
exclude: [/./],
266266
},
267267
rollupOptions: {
268+
external: ['node:async_hooks'],
268269
/**
269270
* This is a workaround to have predictable chunk hashes between builds. It doesn't seem
270271
* to impact the build time.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {
2+
component$,
3+
Resource,
4+
getLocale,
5+
withLocale,
6+
useSignal,
7+
useVisibleTask$,
8+
} from "@qwik.dev/core";
9+
import type { RequestHandler } from "@qwik.dev/router";
10+
import { routeLoader$, server$ } from "@qwik.dev/router";
11+
12+
// Simple in-memory barrier to coordinate two concurrent requests in tests.
13+
type Barrier = {
14+
waiters: Set<string>;
15+
promise?: Promise<void>;
16+
resolve?: () => void;
17+
};
18+
19+
const barriers = new Map<string, Barrier>();
20+
21+
function getBarrier(group: string): Barrier {
22+
let b = barriers.get(group);
23+
if (!b) {
24+
b = { waiters: new Set() };
25+
barriers.set(group, b);
26+
}
27+
return b;
28+
}
29+
30+
function waitForBoth(group: string, id: string) {
31+
const barrier = getBarrier(group);
32+
if (!barrier.promise) {
33+
barrier.promise = new Promise<void>(
34+
(resolve) => (barrier.resolve = resolve),
35+
);
36+
}
37+
barrier.waiters.add(id);
38+
if (barrier.waiters.size >= 2) {
39+
barrier.resolve?.();
40+
}
41+
return barrier.promise!;
42+
}
43+
44+
export const onRequest: RequestHandler = ({ url, locale }) => {
45+
const qpLocale = url.searchParams.get("locale");
46+
if (qpLocale) {
47+
locale(qpLocale);
48+
}
49+
};
50+
51+
export const getAsyncLocale = server$((locale: string) => {
52+
return withLocale(locale, async () => {
53+
await waitForBoth("locale-server", locale);
54+
return getLocale();
55+
});
56+
});
57+
58+
export const useBarrier = routeLoader$(({ url }) => {
59+
const group = url.searchParams.get("group") || "default";
60+
const id = url.searchParams.get("id") || Math.random().toString(36).slice(2);
61+
return waitForBoth(group, id).then(() => ({ done: true }));
62+
});
63+
64+
export default component$(() => {
65+
const serverLocale = useSignal("unknown");
66+
const barrier = useBarrier();
67+
useVisibleTask$(async () => {
68+
serverLocale.value = await getAsyncLocale(getLocale());
69+
});
70+
return (
71+
<section>
72+
<p>
73+
Before barrier locale: <span class="locale-before">{getLocale()}</span>
74+
</p>
75+
<Resource
76+
value={barrier}
77+
onResolved={() => (
78+
<p>
79+
After barrier locale: <span class="locale">{getLocale()}</span>
80+
</p>
81+
)}
82+
/>
83+
<p>
84+
Server locale: <span class="locale-server">{serverLocale.value}</span>
85+
</p>
86+
</section>
87+
);
88+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
// This test ensures asyncRequestStore locale isolation across concurrent requests.
4+
// It triggers two concurrent server renders to the same route with different locales,
5+
// and uses a server-side barrier so the page reveals the locale only after both renders started.
6+
7+
test.describe("Qwik Router concurrent locale", () => {
8+
test("should isolate locale per concurrent request", async ({ browser }) => {
9+
const ctx1 = await browser.newContext();
10+
const ctx2 = await browser.newContext();
11+
12+
const page1 = await ctx1.newPage();
13+
const page2 = await ctx2.newPage();
14+
15+
const url1 =
16+
"/qwikrouter-test/locale-concurrent?group=g&id=one&locale=en-US";
17+
const url2 =
18+
"/qwikrouter-test/locale-concurrent?group=g&id=two&locale=fr-FR";
19+
20+
// Visit both pages concurrently
21+
const nav1 = page1.goto(url1);
22+
const nav2 = page2.goto(url2);
23+
await Promise.all([nav1, nav2]);
24+
25+
await expect(page1.locator(".locale-before")).toHaveText("en-US");
26+
await expect(page2.locator(".locale-before")).toHaveText("fr-FR");
27+
await expect(page1.locator(".locale")).toHaveText("en-US");
28+
await expect(page2.locator(".locale")).toHaveText("fr-FR");
29+
await expect(page1.locator(".locale-server")).toHaveText("en-US");
30+
await expect(page2.locator(".locale-server")).toHaveText("fr-FR");
31+
32+
await ctx1.close();
33+
await ctx2.close();
34+
});
35+
});

0 commit comments

Comments
 (0)