Skip to content

fix(kit): restore sync_store after with_request_store resolves in WebContainer - #16822

Open
okxint wants to merge 2 commits into
sveltejs:version-3from
okxint:fix/webcontainer-sync-store-leak
Open

fix(kit): restore sync_store after with_request_store resolves in WebContainer#16822
okxint wants to merge 2 commits into
sveltejs:version-3from
okxint:fix/webcontainer-sync-store-leak

Conversation

@okxint

@okxint okxint commented Aug 16, 2026

Copy link
Copy Markdown

What

In WebContainer (StackBlitz), AsyncLocalStorage is unavailable, so sync_store is the sole request-context carrier. When a remote query (await query()) completes, the stale query-scoped store — which has is_in_remote_query: true — was left in sync_store. Any user code running after the await would read that store and trigger the throwing getter on event.url, producing:

Cannot access event.url in a query

Fixes #16818.

Root cause

with_request_store used a try/finally pattern:

try {
  sync_store = store;
  return als ? als.run(store, fn) : fn();
} finally {
  if (!IN_WEBCONTAINER) sync_store = null;
}

The finally fires when fn() returns its Promise — not when the Promise resolves. In WebContainer, the block is skipped, leaving sync_store set to store (the query store with is_in_remote_query: true) for the entire lifetime of the request.

Fix

Capture previous = sync_store before the call and restore it via Promise.finally() for async functions, or immediately for synchronous ones. A guard (sync_store === store) prevents the .finally() from clobbering a concurrently-set store.

export function with_request_store(store, fn) {
  const previous = sync_store;
  sync_store = store;

  const result = als ? als.run(store, fn) : fn();

  if (!IN_WEBCONTAINER) {
    sync_store = null;
    return result;
  }

  if (result instanceof Promise) {
    return result.finally(() => {
      if (sync_store === store) sync_store = previous;
    });
  }

  sync_store = previous;
  return result;
}

Tests

Added src/exports/internal/server/event.spec.js with 4 unit tests that mock IN_WEBCONTAINER = true and disable AsyncLocalStorage to exercise the exact code path:

  • Synchronous fn restores outer store
  • Async fn (Promise) restores outer store after resolution ← the regression test
  • Null previous is restored when no outer store exists
  • Concurrent guard prevents .finally() from clobbering a newer store

All tests pass (vitest run --config kit.vitest.config.js).

okxint and others added 2 commits July 25, 2026 12:08
…Container

In environments without AsyncLocalStorage (StackBlitz / WebContainer),
sync_store is the only request-context carrier. The previous implementation
used try/finally, which fires when fn() *returns* the Promise — not when
it *resolves* — leaving sync_store permanently set to the query-scoped store
(is_in_remote_query: true). Any code that ran after `await query()` would
then observe stale state and throw "Cannot access event.url in a query".

The fix captures `previous = sync_store` before the call and restores it
via Promise.finally() for async fns, or immediately for sync fns. A guard
(`sync_store === store`) prevents the .finally() from clobbering a
concurrently-set store.

Fixes sveltejs#16818

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pkg-svelte-dev

Copy link
Copy Markdown

Install the latest version of @sveltejs/kit from c45301d:

pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/c45301df1056dd247231d2e2251555913a43b101

Open in pkg.svelte.dev: https://pkg.svelte.dev/repos/kit/pr/16822

Note

This PR is from a fork. A maintainer must approve approve each commit before it can be built and installed.

@changeset-bot

changeset-bot Bot commented Aug 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c45301d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

const previous = sync_store;
sync_store = store;

const result = als ? als.run(store, fn) : fn();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
const result = als ? als.run(store, fn) : fn();
let result;
try {
result = als ? als.run(store, fn) : fn();
} catch (error) {
// If fn() throws synchronously, reset sync_store before rethrowing so a
// stale store isn't left behind (which would leak into subsequent
// synchronous reads / other requests).
if (!IN_WEBCONTAINER) {
sync_store = null;
} else if (sync_store === store) {
sync_store = previous;
}
throw error;
}

with_request_store fails to reset sync_store when fn() throws synchronously, leaking a stale request store across subsequent synchronous reads and requests.

Fix on Vercel

@dummdidumm dummdidumm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can't do this because it means that you cannot asynchronously access getRequestEvent in web containers anymore (e.g. call it after an await expression inside your query function)

@okxint

okxint commented Aug 18, 2026

Copy link
Copy Markdown
Author

Thanks for the review! I want to make sure I understand the concern correctly before reworking the fix.

My mental model of the fix: the .finally() callback only fires after the entire fn() Promise settles — not at each intermediate await within fn. So for a query function like:

const myQuery = query(async () => {
  const data = await db.fetch()       // ← suspend here
  const e = getRequestEvent()         // ← resume here — sync_store still = queryStore
  return { data }
})

The execution sequence I expect:

  1. with_request_store(queryStore, () => fn(input)) sets sync_store = queryStore
  2. fn(input) starts running, hits await db.fetch(), suspends
  3. with_request_store receives the pending Promise, wraps it: fnPromise.finally(() => sync_store = requestStore)
  4. db.fetch() resolves → fn resumessync_store is still queryStore because .finally() hasn't fired
  5. getRequestEvent() sees sync_store = queryStore
  6. fn completes → fnPromise resolves → .finally() fires → sync_store = requestStore

If that model is wrong — e.g. there's a case where step 4/5 actually sees sync_store = requestStore — could you point to a concrete example? I may be missing something about how run_remote_function's two-part with_request_store calls interact with the scheduling.

The bug I was targeting is user code after await myQuery() seeing is_in_remote_query: true (which is what issue #16818 reports). Happy to approach it differently if there's a cleaner way to fix that without affecting the internal query fn context.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remote functions: form.action throws Cannot access event.url in a query after await query() in WebContainer / StackBlitz

2 participants