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..c94b51763b8 100644 --- a/packages/qwik-router/global.d.ts +++ b/packages/qwik-router/global.d.ts @@ -6,7 +6,6 @@ type 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; type ExperimentalFeatures = import('@qwik.dev/core/optimizer').ExperimentalFeatures; diff --git a/packages/qwik-router/src/adapters/shared/vite/index.ts b/packages/qwik-router/src/adapters/shared/vite/index.ts index 708e54926b4..8debe3c08b0 100644 --- a/packages/qwik-router/src/adapters/shared/vite/index.ts +++ b/packages/qwik-router/src/adapters/shared/vite/index.ts @@ -48,9 +48,11 @@ export function viteAdapter(opts: ViteAdapterPluginOptions) { if (!qwikRouterPlugin) { throw new Error('Missing vite-plugin-qwik-router'); } + // Use double type assertion to avoid TS "Excessive stack depth comparing types" error + // when comparing QwikVitePlugin with Plugin types qwikVitePlugin = config.plugins.find( (p) => p.name === 'vite-plugin-qwik' - ) as QwikVitePlugin; + ) as any as QwikVitePlugin; if (!qwikVitePlugin) { throw new Error('Missing vite-plugin-qwik'); } diff --git a/packages/qwik-router/src/buildtime/vite/plugin.ts b/packages/qwik-router/src/buildtime/vite/plugin.ts index 19d2babf251..79a23097022 100644 --- a/packages/qwik-router/src/buildtime/vite/plugin.ts +++ b/packages/qwik-router/src/buildtime/vite/plugin.ts @@ -133,8 +133,11 @@ function qwikRouterPlugin(userOpts?: QwikRouterVitePluginOptions): any { await validatePlugin(ctx.opts); mdxTransform = await createMdxTransformer(ctx); - - qwikPlugin = config.plugins.find((p) => p.name === 'vite-plugin-qwik') as QwikVitePlugin; + // Use double type assertion to avoid TS "Excessive stack depth comparing types" error + // when comparing QwikVitePlugin with Plugin types + qwikPlugin = config.plugins.find( + (p) => p.name === 'vite-plugin-qwik' + ) as any as QwikVitePlugin; if (!qwikPlugin) { throw new Error('Missing vite-plugin-qwik'); } diff --git a/packages/qwik-router/src/middleware/request-handler/async-request-store.ts b/packages/qwik-router/src/middleware/request-handler/async-request-store.ts new file mode 100644 index 00000000000..bf05e191813 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/async-request-store.ts @@ -0,0 +1,22 @@ +import { isServer } from '@qwik.dev/core'; +import type { RequestEventInternal } from './request-event'; +import type { AsyncLocalStorage } from 'node:async_hooks'; + +export type AsyncStore = AsyncLocalStorage; + +if (isServer) { + import('node:async_hooks') + .then((module) => { + const AsyncLocalStorage = module.AsyncLocalStorage; + asyncRequestStore = new AsyncLocalStorage(); + }) + .catch((err) => { + console.warn( + 'AsyncLocalStorage not available, continuing without it. This might impact concurrent server calls.', + err + ); + }); +} + +// Qwik Core will also be using the async store if this is present +export let asyncRequestStore: AsyncStore | undefined = 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..00a79bfd30e 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 { asyncRequestStore } from './async-request-store'; import type { CacheControl, CacheControlTarget, @@ -80,7 +81,7 @@ export function createRequestEvent( while (routeModuleIndex < requestHandlers.length) { const moduleRequestHandler = requestHandlers[routeModuleIndex]; - const asyncStore = globalThis.qcAsyncRequestStore; + const asyncStore = asyncRequestStore; const result = asyncStore?.run ? asyncStore.run(requestEv, moduleRequestHandler, requestEv) : moduleRequestHandler(requestEv); 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..11d031f5529 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,8 @@ 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 { asyncRequestStore } from './async-request-store'; import type { ServerRequestEvent, StatusCodes } from './types'; // Import separately to avoid duplicate imports in the vite dev server import { @@ -23,20 +25,6 @@ export interface QwikRouterRun { completion: Promise; } -let asyncStore: AsyncStore | undefined; -import('node:async_hooks') - .then((module) => { - const AsyncLocalStorage = module.AsyncLocalStorage; - asyncStore = new AsyncLocalStorage(); - globalThis.qcAsyncRequestStore = asyncStore; - }) - .catch((err) => { - console.warn( - 'AsyncLocalStorage not available, continuing without it. This might impact concurrent server calls.', - err - ); - }); - export function runQwikRouter( serverRequestEv: ServerRequestEvent, loadedRoute: LoadedRoute | null, @@ -57,9 +45,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(), + asyncRequestStore + ? () => asyncRequestStore!.run(requestEv, runNext, requestEv, rebuildRouteInfo, resolve!) + : () => runNext(requestEv, rebuildRouteInfo, resolve!) + ), }; } diff --git a/packages/qwik-router/src/runtime/src/head.ts b/packages/qwik-router/src/runtime/src/head.ts index fdb00342d15..ec3edf63565 100644 --- a/packages/qwik-router/src/runtime/src/head.ts +++ b/packages/qwik-router/src/runtime/src/head.ts @@ -14,6 +14,7 @@ import type { ContentModuleHead, } from './types'; import { isPromise } from './utils'; +import { asyncRequestStore } from '../../middleware/request-handler/async-request-store'; export const resolveHead = ( endpoint: EndpointResponse | ClientPageData, @@ -53,18 +54,25 @@ export const resolveHead = ( } } if (fns.length) { + const hasAsyncStore = !!asyncRequestStore; const headProps: DocumentHeadProps = { head, - withLocale: (fn) => withLocale(locale, fn), + withLocale: hasAsyncStore ? (fn) => fn() : (fn) => withLocale(locale, fn), resolveValue: getData, ...routeLocation, }; - withLocale(locale, () => { + if (hasAsyncStore) { for (const fn of fns) { resolveDocumentHead(head, fn(headProps)); } - }); + } else { + withLocale(locale, () => { + for (const fn of fns) { + resolveDocumentHead(head, fn(headProps)); + } + }); + } } return head; diff --git a/packages/qwik-router/src/runtime/src/server-functions.ts b/packages/qwik-router/src/runtime/src/server-functions.ts index 323486d30a1..32cf842d7e5 100644 --- a/packages/qwik-router/src/runtime/src/server-functions.ts +++ b/packages/qwik-router/src/runtime/src/server-functions.ts @@ -64,6 +64,7 @@ import type { import { useAction, useLocation, useQwikRouterEnv } from './use-functions'; import type { FormSubmitCompletedDetail } from './form-component'; +import { asyncRequestStore } from '../../middleware/request-handler/async-request-store'; import { deepFreeze } from './deepFreeze'; /** @internal */ @@ -420,7 +421,7 @@ export const serverQrl = ( if (isServer) { // Running during SSR, we can call the function directly - let requestEvent = globalThis.qcAsyncRequestStore?.getStore() as RequestEvent | undefined; + let requestEvent = asyncRequestStore?.getStore() as RequestEvent | undefined; if (!requestEvent) { const contexts = [useQwikRouterEnv()?.ev, this, _getContextEvent()] as RequestEvent[]; 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(); + }); +});