Skip to content

Older query response can overwrite a newer value (out-of-order responses, single-flight updates) #17354

Description

@xyrolle

Describe the bug

When a query request is in flight and something newer lands first, the older response still replaces the value when it arrives. Two cases:

  1. A command's single-flight update (get_value().refresh() inside the command) sets the new value, then a refresh() that started before the command finishes and puts the old value back.
  2. Two refresh() calls on the same query whose responses arrive out of order. The second, newer response is shown, then the first response replaces it.

Query already has logic for this. #run drops a response once a newer one has resolved, and set() settles pending requests so they can't apply their results afterwards. But a direct query response has its value applied before that check can protect it. For a normal successful call, the server sends the query's value in _ and also under the query's own key in q ({ _: value, q: { [key]: { v: value } } }). remote_request applies every q entry with entry.resource.set(...), including the query's own entry. So the value is already applied before #run checks whether the response is still current, and the function passed to QueryProxy resolves with undefined. As a result, the latest response to arrive always wins, no matter how old its request is.

This seems to date from #15991 (first released in 2.65.0), which replaced returning the parsed value with the side-channel q data.

I have a fix with unit tests and will open a PR.

Reproduction

Repository: https://github.com/xyrolle/sveltekit-query-order-repro (the same code is below).

With remote functions enabled (sveltekit({ experimental: { remoteFunctions: true } }) in vite.config.js), case 1 happens in Chromium and WebKit:

// src/routes/race/data.remote.js
import { command, query } from '$app/server';

let value = 0;
let slow = false;

export const get_value = query(async () => {
	const v = value;
	if (slow) {
		slow = false;
		await new Promise((resolve) => setTimeout(resolve, 1000));
	}
	return v;
});

export const slow_down_next_read = command(() => {
	slow = true;
});

export const increment = command(async () => {
	value += 1;
	get_value().refresh();
});
<!-- src/routes/race/+page.svelte -->
<script>
	import { get_value, increment, slow_down_next_read } from './data.remote';

	const v = get_value();
</script>

<p>{v.current}</p>
<button
	onclick={async () => {
		await slow_down_next_read();
		v.refresh(); // reads the value before the increment, responds after 1s
		await new Promise((resolve) => setTimeout(resolve, 100));
		await increment(); // responds right away with the incremented value
	}}>go</button
>

Click "go". The value goes from 0 to 1 when the command responds. About a second later the refresh responds, and the value goes back to 0.

Case 2 needs a query that returns something different on every call, for example a counter that increments on each request. Call v.refresh() twice, where the first request is slow:

// src/routes/race2/data.remote.js
import { command, query } from '$app/server';

let n = 0;
let slow = false;

export const get_count = query(async () => {
	const c = ++n;
	if (slow) {
		slow = false;
		await new Promise((resolve) => setTimeout(resolve, 1000));
	}
	return c;
});

export const slow_down_next_read = command(() => {
	slow = true;
});
<!-- src/routes/race2/+page.svelte -->
<script>
	import { get_count, slow_down_next_read } from './data.remote';

	const v = get_count();
</script>

<p>{v.current}</p>
<button
	onclick={async () => {
		await slow_down_next_read();
		v.refresh(); // first request, responds after 1s
		await new Promise((resolve) => setTimeout(resolve, 100));
		v.refresh(); // second request, responds right away
	}}>go</button
>

The second, newer value is shown and then replaced by the first one. I could reproduce this in WebKit. In Chromium this setup doesn't show it, because Chromium held the second identical GET until the first one responded.

Expected behaviour

The value should stay at the newest result: 1 in case 1, and the second refresh's value in case 2. A response that has been superseded should be discarded, the same way Query#run already handles it.

Logs

No errors in the browser console or the server output.

System Info

System:
  OS: macOS 27.2
  CPU: (18) arm64 Apple M5 Pro
Binaries:
  Node: 24.16.0
  npm: 11.13.0
Browsers:
  Chrome: 154.0.8037.93
  Safari: 27.2
npmPackages:
  @sveltejs/kit: 3.0.0 => 3.0.0
  @sveltejs/vite-plugin-svelte: 7.3.1 => 7.3.1
  svelte: 5.57.1 => 5.57.1
  vite: 8.3.2 => 8.3.2

Also reproduced on current main (7b4bb3c), in Chromium 153 and WebKit 26.6.

Severity

serious, but I can work around it

Additional Information

In the client, a direct query that isn't redirected returns its own result through Query#run, and remote_request skips that key. Redirect responses keep the current behaviour. If the query updated itself on the server, its own q entry is used, so that still works. Other queries' entries in q (command/form single-flight updates) are still applied as before (set(), or fail() for errors). One behaviour change: if a query refreshes itself on the server and that refresh fails, refresh() now rejects (as for any other query error) instead of resolving with .error set. Normal query.batch results aren't affected because they already come back through the promise.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions