sveltejs / sveltejs/kit

Remote functions fire during hydration with placeholder `page.url`, causing wrong server-side route resolution

Open
#15,749 5 comments 0 reactions 0 assignees View on GitHub

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:

  1. runtime/client/state.svelte.js initializes page.url = new URL('a:'). new URL('a:').pathname === ''.
  2. runtime/client/client.js assigns the real page state via Object.assign(page, result.props.page) only after the hydration data load completes.
  3. runtime/client/remote-functions/shared.svelte.js reads page.url.pathname synchronously 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
  1. Start the dev server and navigate to /foo.
  2. Hard-refresh.
  3. Observe a 400 No slug param error.

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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.