Skip to content

Fix duplicate single-flight revalidation#575

Closed
brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:fix/single-flight-double-revalidation
Closed

Fix duplicate single-flight revalidation#575
brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:fix/single-flight-double-revalidation

Conversation

@brenelz

@brenelz brenelz commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • detect server-function-backed actions, including synthesized server form actions
  • skip the action layer metadata pass when the enabled single-flight transport already consumed it
  • preserve default revalidation for client actions and for server actions with single flight disabled

Problem

The single-flight transport consumer applies X-Revalidate metadata and seeds the query cache before returning the unwrapped mutation value. The action layer then treated that plain value as a fresh action result and applied default metadata handling again. That second pass invalidated the newly seeded cache and intermittently issued a follow-up query GET.

Verification

  • pnpm test (306 client tests, 28 server tests, type tests)
  • pnpm build
  • reproduced in Chrome before the fix as POST + GET; verified five repeated submissions produce only the mutation POST after the fix

@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: defb7dd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@ryansolid ryansolid 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.

The diagnosis is right and I can confirm the double pass from reading the code: after the transport consumer applies X-Revalidate and seeds the cache, handleResponse sees the unwrapped plain value, falls into else data = response, and calls applyResponseMetadata(undefined, ...) — which invalidates every cache entry (cacheKeyOp(undefined, ...)) and refetches. So the bug and the test coverage are solid. Thanks for running it down.

The guard condition is the problem: isServerFunction(fn) && router.singleFlight assumes the consumer ran, but there are reachable cases where a server function + single-flight-enabled router still settles with a plain value without the consumer ever being invoked — and in those cases this PR silently drops the default revalidation the old code performed:

  1. The collector returns undefined. foldFlightData in @solidjs/web only sets the X-Single-Flight response header when the hook returns data. The router's own createFlightDataCollector returns undefined for: no referer header (e.g. Referrer-Policy: no-referrer, non-browser callers), raw body-carrying Response outcomes, cross-origin redirect targets, and — most commonly — when the data-only preload pass collects nothing. That last one hits any app whose queries run in components rather than route preloads: collection comes back empty, no header, transport returns the decoded plain value without touching the consumer, and the mutation then triggers no revalidation at all. Those component-level queries stay stale.
  2. No collectFlightData hook configured server-side (integration without single-flight support): same plain-value path.

Suggestion: instead of predicting from the function's identity, track whether the consumer actually ran during this invocation. The consumer is awaited inside fetchServerFunction before the mutation promise resolves, so the sequencing is reliable:

let flightApplications = 0;

export function setupFlightDataConsumer(router: RouterContext) {
  return subscribeFlightData<Record<string, any>>((data, { response }) => {
    flightApplications++;
    return applyResponseMetadata(response, router.navigatorFactory(), data);
  });
}

then in invoke, capture flightApplications before running the mutation and pass metadataHandled: flightApplications !== before to handleResponse, keeping your if (!metadataHandled || metadata || flightData) shape. That fixes the duplicate pass identically in the happy path, preserves default revalidation in every fallback path, and drops the need for isServerFunction and the explicit flag on createServerFormAction entirely (a wrapper losing the metadata brand stops mattering). Two overlapping mutations could theoretically cross-attribute a consumer run, but that's a far smaller window than the cases above and errs toward a redundant revalidation rather than a missed one — worth a comment, not machinery.

Would also be good to extend the "no flight payload" test to assert the plain-value server-function path (consumer never invoked) still revalidates.

ryansolid added a commit that referenced this pull request Jul 23, 2026
Replaces the isServerFunction && router.singleFlight guard from #575 with
a counter on the flight-data consumer: an action compares it across its
mutation to learn whether this response's metadata was actually applied.
Predicting from the function's identity missed every response the server
returned without flight data (no referrer, empty collection pass, no
server-side collector), silently dropping the default revalidation those
paths rely on. Adds a regression test for the bare-value server response.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid

Copy link
Copy Markdown
Member

Landed on next: your branch was merged as-is (525508f — the diagnosis, the guard shape in handleResponse, and your tests are all in), with a follow-up commit (c1caab1) swapping the isServerFunction && router.singleFlight prediction for a consumer-run counter per the review, so bare-value server responses (no referrer / empty collection / no server-side collector) keep their default revalidation. Added a regression test for that path plus a changeset. Thanks for the fix and the thorough writeup — the POST+GET repro made this easy to verify.

@ryansolid ryansolid closed this Jul 23, 2026
@brenelz

brenelz commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Yeah i did this pr with 5.6 sol instead of fable. I think fable would have done better. At least it helped narrow down the issue

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.

2 participants