Remote functions fire during hydration with placeholder `page.url`, causing wrong server-side route resolution
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 20.8k
- Forks
- 2.3k
- Avg merge
- 1d 16h
- Merged PRs (30d)
- 156
Description
A remote query invoked from a $derived (or any synchronously-evaluated reactive scope) during the initial hydration of a page can fire before SvelteKit has assigned the real URL to page.url. At that moment page.url is still the placeholder new URL('a:'), whose pathname is the empty string "".
The client helper get_remote_request_headers (in runtime/client/remote-functions/shared.svelte.js) sends page.url.pathname as the x-sveltekit-pathname header. The server's respond.js then does:
url.pathname = request.headers.get('x-sveltekit-pathname') ?? base;
…and find_route('') resolves the request to the wrong route — typically a layout group root rather than the actual page. Route params like [slug] end up missing, and any server-side logic that depends on them fails (400/404 in the best case; in the worst case, silent data leakage to a different tenant/scope).
Root cause
Three SvelteKit internals interact:
runtime/client/state.svelte.jsinitializespage.url = new URL('a:').new URL('a:').pathname === ''.runtime/client/client.jsassigns the real page state viaObject.assign(page, result.props.page)only after the hydration data load completes.runtime/client/remote-functions/shared.svelte.jsreadspage.url.pathnamesynchronously when building the request headers.
A $derived containing getQuery(...) evaluates as soon as the component instantiates. Because <svelte:boundary> with a pending snippet skips its async children on the server, the hydratable cache often misses → the client refetches → the fetch fires before the page-state assignment commits → empty pathname header → wrong route on the server.
The race window is small but reliably hit when the $derived re-runs (e.g., another async query resolving), because the second invocation races with the first one's pending state.
Expected behavior
Remote functions should not be able to fire with a placeholder page.url, or get_remote_request_headers should fall back to location.pathname when page.url.pathname === ''.
Suggested fix
In get_remote_request_headers, fall back to location when page.url.pathname is empty:
export function get_remote_request_headers() {
return untrack(() => {
const url = navigating.current?.to?.url ?? page.url
const pathname = url.pathname || location.pathname
const search = url.pathname ? url.search : location.search
return {
'x-sveltekit-pathname': pathname,
'x-sveltekit-search': search,
}
})
}
(location is always populated in the browser, and these helpers only run client-side.)
Workaround
Gate the remote call until page.url.pathname is populated:
<script lang="ts">
import { page } from '$app/state'
import { getData } from './data.remote'
let dataQuery = $derived.by(() => {
if (!page.url.pathname) return undefined
return getData({})
})
const pendingPlaceholder = new Promise<never>(() => {})
</script>
<svelte:boundary>
{@const result = await (dataQuery ?? pendingPlaceholder)}
<!-- ... -->
{#snippet pending()}<p>Loading…</p>{/snippet}
</svelte:boundary>
Reproduction
src/routes/[slug]/data.remote.ts
import { query, getRequestEvent } from '$app/server'
import { error } from '@sveltejs/kit'
import { z } from 'zod'
export const getData = query(z.object({}), async () => {
const { params } = getRequestEvent()
if (!params.slug) error(400, 'No slug param — wrong route resolved!')
return { slug: params.slug }
})
export const getOtherThing = query(z.object({}), async () => ({ ok: true }))
src/routes/[slug]/+page.svelte
<script lang="ts">
import { getData, getOtherThing } from './data.remote'
// Reading `.current` on another remote query causes the $derived to re-run
// when it resolves — reliably re-firing getData during hydration and
// racing against the real page.url being committed.
let other = $derived(getOtherThing().current)
let dataQuery = $derived.by(() => {
void other
return getData({})
})
</script>
<svelte:boundary>
{@const result = await dataQuery}
<pre>{JSON.stringify(result)}</pre>
{#snippet pending()}<p>Loading…</p>{/snippet}
{#snippet failed(err)}<pre>{JSON.stringify(err)}</pre>{/snippet}
</svelte:boundary>
Steps
- Start the dev server and navigate to
/foo. - Hard-refresh.
- Observe a
400 No slug paramerror.
DevTools → Network shows the failing request:
GET /_app/remote/<hash>/getData?payload=...
x-sveltekit-pathname: ← empty!
System Info
- SvelteKit: 2.57.1
- Svelte: 5.55.4 (with `compilerOptions.experimental.async: true`)
- `kit.experimental.remoteFunctions: true`
- Adapter: `@sveltejs/adapter-node`
- Node: 22.x
- Browser: Chrome 137
Severity
annoyance
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading runtime/client/remote-functions/shared.svelte.js and runtime/client/client.js, then trace server/respond.js while running the [slug] reproduction described in the issue. Confirm the hydration request carries the actual pathname and that route params resolve correctly without the 400 error.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- api, backend, frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100