fix(next): isolate per-request state to prevent concurrent-request race#1124
Conversation
Per-request state (CookieStorage and the navigation URL) was stored on the LogtoClient instance, which apps use as a module-level singleton. Concurrent requests clobbered each other across the `await storage.init()` yield point, intermittently reporting authenticated users as signed out. Keep all per-request state in request-scoped locals threaded through the return value of the internal createNodeClient* helpers across the Pages Router, Edge, and server-actions entry points. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Great fix — the race condition analysis is spot-on and the per-request isolation approach is the right solution. One concern: I'd suggest splitting each into a public wrapper (preserving the original return type) + a private implementation method. See the inline comments for a concrete example. This keeps the concurrent-request fix intact for all internal handler methods while maintaining full backward compatibility for external consumers. |
…ible
The per-request isolation fix changed the return type of the public
createNodeClientFromNextApi / createNodeClient helpers from the node client to a
{ nodeClient, storage, getNavigateUrl } bundle, which breaks direct callers
(including the documented "fetch organization tokens" snippet) and is not a valid
patch-level change. Keep the public methods' original return types and move the
per-request bundle into a private createRequestScopedClient used by internal callers.
Summary
@logto/nextkept per-request state — the request'sCookieStorageand the navigation URL — on instance fields (this.storage/this.navigateUrl) ofLogtoClient. Apps instantiate this client as a module-level singleton (the pattern our own docs recommend), so all requests share those fields.createNodeClientFromNextApi(and the Edge / server-actions equivalents) did:The
awaitat ② is a yield point. With two concurrent requests A and B on the same singleton:Request A then builds its client with B's storage. If B's
init()(async cookie decrypt) hasn't resolved, the session is still empty, sogetContext()returnsisAuthenticated: false(and empty claims) for a fully authenticated user. The sharednavigatecallback has the same flaw, so concurrent sign-in/out can redirect to another request's target. User-visible symptom: the session intermittently "flickers" to signed-out on refresh and recovers after a reload.Why this is an SDK defect, not misuse
Concurrency is the normal operating condition for a server-side request handler, not an edge case — a single page firing two parallel auth-gated requests (e.g. two
useSWRhooks) is enough to trigger it, and the singleton usage that triggers it is exactly what the docs recommend. A per-request handler is expected to be reentrant. This fix removes the shared mutable state rather than serializing access to it, so concurrent requests are isolated with zero added latency or locking.Fix
Keep all per-request state in request-scoped locals, threaded through the return value of the internal
createNodeClient*helpers instead ofthis.*. A smallNavigationStore(insrc/utils.ts) captures thenavigatecallback's URL per request. The shared instance fields are removed from the base client. Applied consistently across all three entry points: Pages Router (@logto/next), Edge (@logto/next/edge), and server-actions (@logto/next/server-actions).Testing
Unit tests
Added a deterministic regression test (
keeps storage isolated across concurrent requests) that starts twocreateNodeClientFromNextApicalls before awaiting either, so request B interleaves through request A'sinit()yield point, and asserts each request receives its own storage instance. Existing suites for all three entry points pass.Checklist
.changeset