diff --git a/.changeset/pretty-parents-draw.md b/.changeset/pretty-parents-draw.md new file mode 100644 index 00000000000..5dc3372ef3f --- /dev/null +++ b/.changeset/pretty-parents-draw.md @@ -0,0 +1,6 @@ +--- +'@qwik.dev/router': patch +'@qwik.dev/core': patch +--- + +enhance locale handling with AsyncLocalStorage support for server-side requests diff --git a/packages/qwik-router/global.d.ts b/packages/qwik-router/global.d.ts index 477fb3336ea..cc81dcec280 100644 --- a/packages/qwik-router/global.d.ts +++ b/packages/qwik-router/global.d.ts @@ -5,7 +5,6 @@ type RequestEventInternal = import('./middleware/request-handler/request-event').RequestEventInternal; type AsyncStore = import('node:async_hooks').AsyncLocalStorage; type SerializationStrategy = import('@qwik.dev/core/internal').SerializationStrategy; - declare var qcAsyncRequestStore: AsyncStore | undefined; declare var _qwikActionsMap: Map | undefined; diff --git a/packages/qwik-router/src/middleware/request-handler/request-event.ts b/packages/qwik-router/src/middleware/request-handler/request-event.ts index 59b944f2474..8b36afd4a92 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event.ts @@ -20,6 +20,7 @@ import { RewriteMessage, } from '@qwik.dev/router/middleware/request-handler'; import { encoder, getRouteLoaderPromise } from './resolve-request-handlers'; + import type { CacheControl, CacheControlTarget, diff --git a/packages/qwik-router/src/middleware/request-handler/user-response.ts b/packages/qwik-router/src/middleware/request-handler/user-response.ts index aeb525b771a..f43f0e6564e 100644 --- a/packages/qwik-router/src/middleware/request-handler/user-response.ts +++ b/packages/qwik-router/src/middleware/request-handler/user-response.ts @@ -8,6 +8,7 @@ import type { import { getErrorHtml } from './error-handler'; import { createRequestEvent, getRequestMode, type RequestEventInternal } from './request-event'; import { encoder } from './resolve-request-handlers'; +import { withLocale } from '@qwik.dev/core'; import type { ServerRequestEvent, StatusCodes } from './types'; // Import separately to avoid duplicate imports in the vite dev server import { @@ -17,12 +18,6 @@ import { ServerError, } from '@qwik.dev/router/middleware/request-handler'; -export interface QwikRouterRun { - response: Promise; - requestEv: RequestEvent; - completion: Promise; -} - let asyncStore: AsyncStore | undefined; import('node:async_hooks') .then((module) => { @@ -36,6 +31,11 @@ import('node:async_hooks') err ); }); +export interface QwikRouterRun { + response: Promise; + requestEv: RequestEvent; + completion: Promise; +} export function runQwikRouter( serverRequestEv: ServerRequestEvent, @@ -57,9 +57,12 @@ export function runQwikRouter( return { response: responsePromise, requestEv, - completion: asyncStore - ? asyncStore.run(requestEv, runNext, requestEv, rebuildRouteInfo, resolve!) - : runNext(requestEv, rebuildRouteInfo, resolve!), + completion: withLocale( + requestEv.locale(), + asyncStore + ? () => asyncStore!.run(requestEv, runNext, requestEv, rebuildRouteInfo, resolve!) + : () => runNext(requestEv, rebuildRouteInfo, resolve!) + ), }; } diff --git a/packages/qwik/src/core/use/use-locale.ts b/packages/qwik/src/core/use/use-locale.ts index 5e3efdddabb..da62788cc33 100644 --- a/packages/qwik/src/core/use/use-locale.ts +++ b/packages/qwik/src/core/use/use-locale.ts @@ -1,7 +1,21 @@ import { tryGetInvokeContext } from './use-core'; +import { isServer } from '@qwik.dev/core/build'; +import type { AsyncLocalStorage } from 'node:async_hooks'; let _locale: string | undefined = undefined; +let localAsyncStore: AsyncLocalStorage | undefined; + +if (isServer) { + import('node:async_hooks') + .then((module) => { + localAsyncStore = new module.AsyncLocalStorage(); + }) + .catch(() => { + // ignore if AsyncLocalStorage is not available + }); +} + /** * Retrieve the current locale. * @@ -11,6 +25,14 @@ let _locale: string | undefined = undefined; * @public */ export function getLocale(defaultLocale?: string): string { + // Prefer per-request locale from local AsyncLocalStorage if available (server-side) + if (localAsyncStore) { + const locale = localAsyncStore.getStore(); + if (locale) { + return locale; + } + } + if (_locale === undefined) { const ctx = tryGetInvokeContext(); if (ctx && ctx.$locale$) { @@ -30,6 +52,10 @@ export function getLocale(defaultLocale?: string): string { * @public */ export function withLocale(locale: string, fn: () => T): T { + if (localAsyncStore) { + return localAsyncStore.run(locale, fn); + } + const previousLang = _locale; try { _locale = locale; diff --git a/starters/apps/qwikrouter-test/src/routes/locale-concurrent/index.tsx b/starters/apps/qwikrouter-test/src/routes/locale-concurrent/index.tsx new file mode 100644 index 00000000000..e0d9c011ce0 --- /dev/null +++ b/starters/apps/qwikrouter-test/src/routes/locale-concurrent/index.tsx @@ -0,0 +1,67 @@ +import { component$, Resource, getLocale } from "@qwik.dev/core"; +import type { RequestHandler } from "@qwik.dev/router"; +import { routeLoader$ } from "@qwik.dev/router"; + +// Simple in-memory barrier to coordinate two concurrent requests in tests. +type Barrier = { + waiters: Set; + promise?: Promise; + resolve?: () => void; +}; + +const barriers = new Map(); + +function getBarrier(group: string): Barrier { + let b = barriers.get(group); + if (!b) { + b = { waiters: new Set() }; + barriers.set(group, b); + } + return b; +} + +function waitForBoth(group: string, id: string) { + const barrier = getBarrier(group); + if (!barrier.promise) { + barrier.promise = new Promise( + (resolve) => (barrier.resolve = resolve), + ); + } + barrier.waiters.add(id); + if (barrier.waiters.size >= 2) { + barrier.resolve?.(); + } + return barrier.promise!; +} + +export const onRequest: RequestHandler = ({ url, locale }) => { + const qpLocale = url.searchParams.get("locale"); + if (qpLocale) { + locale(qpLocale); + } +}; + +export const useBarrier = routeLoader$(({ url }) => { + const group = url.searchParams.get("group") || "default"; + const id = url.searchParams.get("id") || Math.random().toString(36).slice(2); + return waitForBoth(group, id).then(() => ({ done: true })); +}); + +export default component$(() => { + const barrier = useBarrier(); + return ( +
+

+ Before barrier locale: {getLocale()} +

+ ( +

+ After barrier locale: {getLocale()} +

+ )} + /> +
+ ); +}); diff --git a/starters/e2e/qwikrouter/locale-concurrent.e2e.ts b/starters/e2e/qwikrouter/locale-concurrent.e2e.ts new file mode 100644 index 00000000000..199ee98f42a --- /dev/null +++ b/starters/e2e/qwikrouter/locale-concurrent.e2e.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; + +// This test ensures asyncRequestStore locale isolation across concurrent requests. +// It triggers two concurrent server renders to the same route with different locales, +// and uses a server-side barrier so the page reveals the locale only after both renders started. + +test.describe("Qwik Router concurrent locale", () => { + test("should isolate locale per concurrent request", async ({ browser }) => { + const ctx1 = await browser.newContext(); + const ctx2 = await browser.newContext(); + + const page1 = await ctx1.newPage(); + const page2 = await ctx2.newPage(); + + const url1 = + "/qwikrouter-test/locale-concurrent?group=g&id=one&locale=en-US"; + const url2 = + "/qwikrouter-test/locale-concurrent?group=g&id=two&locale=fr-FR"; + + // Start both navigations without waiting them to finish + const nav1 = page1.goto(url1); + const nav2 = page2.goto(url2); + + await Promise.all([nav1, nav2]); + + // Before barrier render, locale is already set and visible in first block + await expect(page1.locator(".locale-before")).toHaveText("en-US"); + await expect(page2.locator(".locale-before")).toHaveText("fr-FR"); + + // After barrier releases, the bottom content renders and must preserve each locale + await expect(page1.locator(".locale")).toHaveText("en-US"); + await expect(page2.locator(".locale")).toHaveText("fr-FR"); + + await ctx1.close(); + await ctx2.close(); + }); +});