Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pretty-parents-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@qwik.dev/router': patch
'@qwik.dev/core': patch
---

enhance locale handling with AsyncLocalStorage support for server-side requests
1 change: 0 additions & 1 deletion packages/qwik-router/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ type RequestEventInternal =
type AsyncStore = import('node:async_hooks').AsyncLocalStorage<RequestEventInternal>;
type SerializationStrategy = import('@qwik.dev/core/internal').SerializationStrategy;

declare var qcAsyncRequestStore: AsyncStore | undefined;
declare var _qwikActionsMap: Map<string, ActionInternal> | undefined;

/** @deprecated Will be removed in v3 */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { isServer } from 'packages/qwik/dist';
import type { RequestEventInternal } from './request-event';
import type { AsyncLocalStorage } from 'node:async_hooks';

export type AsyncStore = AsyncLocalStorage<RequestEventInternal>;

if (isServer) {
import('node:async_hooks')
.then((module) => {
const AsyncLocalStorage = module.AsyncLocalStorage;
asyncRequestStore = new AsyncLocalStorage<RequestEventInternal>();
})
.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;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -83,7 +84,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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
} from './request-event';
import { encoder } from './resolve-request-handlers';
import type { QwikSerializer, ServerRequestEvent, StatusCodes } from './types';
import { withLocale } from '@qwik.dev/core';
import { asyncRequestStore } from './async-request-store';
// Import separately to avoid duplicate imports in the vite dev server
import {
AbortMessage,
Expand All @@ -27,20 +29,6 @@ export interface QwikRouterRun<T> {
completion: Promise<unknown>;
}

let asyncStore: AsyncStore | undefined;
import('node:async_hooks')
.then((module) => {
const AsyncLocalStorage = module.AsyncLocalStorage;
asyncStore = new AsyncLocalStorage<RequestEventInternal>();
globalThis.qcAsyncRequestStore = asyncStore;
})
.catch((err) => {
console.warn(
'AsyncLocalStorage not available, continuing without it. This might impact concurrent server calls.',
err
);
});

export function runQwikRouter<T>(
serverRequestEv: ServerRequestEvent<T>,
loadedRoute: LoadedRoute | null,
Expand All @@ -63,9 +51,12 @@ export function runQwikRouter<T>(
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!)
),
};
}

Expand Down
8 changes: 6 additions & 2 deletions packages/qwik-router/src/runtime/src/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
ActionInternal,
} from './types';
import { isPromise } from './utils';
import { asyncRequestStore as hasAsyncStore } from '../../middleware/request-handler/async-request-store';

export const resolveHead = (
endpoint: EndpointResponse | ClientPageData,
Expand All @@ -37,9 +38,10 @@ export const resolveHead = (
}
return data;
}) as any as ResolveSyncValue;
// Qwik Core will also be using the async store if this is present
const headProps: DocumentHeadProps = {
head,
withLocale: (fn) => withLocale(locale, fn),
withLocale: hasAsyncStore ? (fn) => fn() : (fn) => withLocale(locale, fn),
resolveValue: getData,
...routeLocation,
};
Expand All @@ -50,7 +52,9 @@ export const resolveHead = (
if (typeof contentModuleHead === 'function') {
resolveDocumentHead(
head,
withLocale(locale, () => contentModuleHead(headProps))
hasAsyncStore
? contentModuleHead(headProps)
: withLocale(locale, () => contentModuleHead(headProps))
);
} else if (typeof contentModuleHead === 'object') {
resolveDocumentHead(head, contentModuleHead);
Expand Down
3 changes: 2 additions & 1 deletion packages/qwik-router/src/runtime/src/server-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { useAction, useLocation, useQwikRouterEnv } from './use-functions';

import type { FormSubmitCompletedDetail } from './form-component';
import { deepFreeze } from './utils';
import { asyncRequestStore } from '../../middleware/request-handler/async-request-store';

/** @internal */
export const routeActionQrl = ((
Expand Down Expand Up @@ -420,7 +421,7 @@ export const serverQrl = <T extends ServerFunction>(

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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you say it has to be a global for server-functions.ts, but it's not using the global?


if (!requestEvent) {
const contexts = [useQwikRouterEnv()?.ev, this, _getContextEvent()] as RequestEvent[];
Expand Down
26 changes: 26 additions & 0 deletions packages/qwik/src/core/use/use-locale.ts
Original file line number Diff line number Diff line change
@@ -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<string> | undefined;

if (isServer) {
import('node:async_hooks')
.then((module) => {
localAsyncStore = new module.AsyncLocalStorage();
})
.catch(() => {
// ignore if AsyncLocalStorage is not available
});
}

/**
* Retrieve the current locale.
*
Expand All @@ -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$) {
Expand All @@ -30,6 +52,10 @@ export function getLocale(defaultLocale?: string): string {
* @public
*/
export function withLocale<T>(locale: string, fn: () => T): T {
if (localAsyncStore) {
return localAsyncStore.run(locale, fn);
}

const previousLang = _locale;
try {
_locale = locale;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>;
promise?: Promise<void>;
resolve?: () => void;
};

const barriers = new Map<string, Barrier>();

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<void>(
(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 (
<section>
<p>
Before barrier locale: <span class="locale-before">{getLocale()}</span>
</p>
<Resource
value={barrier}
onResolved={() => (
<p>
After barrier locale: <span class="locale">{getLocale()}</span>
</p>
)}
/>
</section>
);
});
37 changes: 37 additions & 0 deletions starters/e2e/qwikrouter/locale-concurrent.e2e.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});