Summary
A redirect() thrown (or returned) from a server-function action with a relative target (Location: /) sometimes fails to navigate at all on the client — no error, no console output, no URL change — while the action response itself is perfectly valid and the single-flight query cache still updates. Sending the identical redirect as a same-origin absolute URL (Location: http://localhost:3000/) navigates reliably 100% of the time.
Versions:
@solidjs/router@2.0.0-next.18
@solidjs/web@2.0.0-rc.3
- Solid
2.0.0-rc.3 (streaming SSR)
Reproduction
A login/register/logout implemented as a server-function action:
export const login = action(async (formData: FormData) => {
'use server';
// …validate, hash, setSession({ userId })…
throw redirect('/'); // relative → SOMETIMES does not navigate
});
Observed wire response (identical whether the redirect is thrown or returned):
HTTP/1.1 200 OK
location: /
x-server-function-error: true
x-server-function-format: 0
x-single-flight: true
set-cookie: session=…; HttpOnly; Max-Age=604800; Path=/; SameSite=Lax; Secure
Expected: the client navigates to / after the action settles.
Actual: in some environments/browsers the page never navigates. A manual refresh reveals the correct state (the cookie was set server-side, and the single-flight data did update the query cache) — only the URL never moves. There are no console errors. In our reproduction environment (real headless Chrome, both dev and production builds) navigation succeeded, while a user's browser hit the failure consistently, so it is timing/environment-sensitive.
The same action with an absolute target always works:
throw redirect(new URL(path, getRequestEvent()!.request.url).toString()); // works reliably
Root-cause analysis
The client-side redirect policy is identical between the old @solidjs/router@0.14/0.15 (SolidStart 1.x era) and v2 — both do:
if (locationUrl.startsWith("http")) {
window.location.href = locationUrl; // hard navigation — reliable
} else {
navigate(locationUrl); // soft navigation
}
The difference is the soft-navigation engine. The old router committed inside startTransition(() => setReference(...)) with a guaranteed .finally() teardown. The v2 router commits via setNavigateTarget(...) plus a cancelable queueMicrotask (@solidjs/router/dist/index.js, navigateFromRoute):
intent = "navigate";
lastTransitionTarget = newTarget;
if (firstNavigation) { setIsRouting(true); flush(); }
if (lastTransitionTarget === newTarget) {
setNavigateTarget({ ...lastTransitionTarget });
queueMicrotask(() => {
if (lastTransitionTarget !== newTarget) return; // ← silently skips the URL commit
intent = undefined;
navigateEnd(lastTransitionTarget);
setNavigateTarget(undefined);
setIsRouting(false);
lastTransitionTarget = undefined;
});
}
If lastTransitionTarget is overwritten by a second navigation between setNavigateTarget and the microtask — or if a Solid 2 transition/flush scheduling interaction interferes — the URL commit is silently dropped. Additionally, for action redirects the navigate() call now originates from inside the flight-data consumer (setupFlightDataConsumer → applyResponseMetadata), i.e. before the action settles, which puts the navigation in a different async/timing window than the old router (which navigated from the action's settled handler).
Questions
- Is the
lastTransitionTarget === newTarget guard in the microtask intended to silently drop superseded navigations? If so, is silently dropping a server-initiated redirect acceptable without any fallback (e.g. a hard location.assign to the last target)?
- Should redirects with a relative
Location that originate from server functions prefer the hard branch (window.location.assign(new URL(locationUrl, location.href))), reserving soft navigation for same-document UI navigations?
- Is there a known interaction between Solid 2's "every write is a transition" model and calling
navigate() from inside the flight-data consumer (pre-settle) that could make the microtask commit unreliable under certain hydration/timing conditions?
Expected behavior
A relative redirect from a server-function action should either navigate (soft or hard) or fail loudly — never silently do nothing while the cookie/data update succeeded.
- Environment: Solid 2.0 rc, streaming SSR,
@solidjs/server-functions transport with single-flight enabled.
- Workaround currently shipped in our codebase: emit the redirect target as a same-origin absolute URL; verify before merging any opinionated fix.
- Happy to provide a minimal repro repo or to bisect with a specific browser/timing setup if useful.
Summary
A
redirect()thrown (or returned) from a server-function action with a relative target (Location: /) sometimes fails to navigate at all on the client — no error, no console output, no URL change — while the action response itself is perfectly valid and the single-flight query cache still updates. Sending the identical redirect as a same-origin absolute URL (Location: http://localhost:3000/) navigates reliably 100% of the time.Versions:
@solidjs/router@2.0.0-next.18@solidjs/web@2.0.0-rc.32.0.0-rc.3(streaming SSR)Reproduction
A login/register/logout implemented as a server-function action:
Observed wire response (identical whether the redirect is thrown or returned):
Expected: the client navigates to
/after the action settles.Actual: in some environments/browsers the page never navigates. A manual refresh reveals the correct state (the cookie was set server-side, and the single-flight data did update the query cache) — only the URL never moves. There are no console errors. In our reproduction environment (real headless Chrome, both dev and production builds) navigation succeeded, while a user's browser hit the failure consistently, so it is timing/environment-sensitive.
The same action with an absolute target always works:
Root-cause analysis
The client-side redirect policy is identical between the old
@solidjs/router@0.14/0.15(SolidStart 1.x era) and v2 — both do:The difference is the soft-navigation engine. The old router committed inside
startTransition(() => setReference(...))with a guaranteed.finally()teardown. The v2 router commits viasetNavigateTarget(...)plus a cancelablequeueMicrotask(@solidjs/router/dist/index.js,navigateFromRoute):If
lastTransitionTargetis overwritten by a second navigation betweensetNavigateTargetand the microtask — or if a Solid 2 transition/flush scheduling interaction interferes — the URL commit is silently dropped. Additionally, for action redirects thenavigate()call now originates from inside the flight-data consumer (setupFlightDataConsumer→applyResponseMetadata), i.e. before the action settles, which puts the navigation in a different async/timing window than the old router (which navigated from the action's settled handler).Questions
lastTransitionTarget === newTargetguard in the microtask intended to silently drop superseded navigations? If so, is silently dropping a server-initiated redirect acceptable without any fallback (e.g. a hardlocation.assignto the last target)?Locationthat originate from server functions prefer the hard branch (window.location.assign(new URL(locationUrl, location.href))), reserving soft navigation for same-document UI navigations?navigate()from inside the flight-data consumer (pre-settle) that could make the microtask commit unreliable under certain hydration/timing conditions?Expected behavior
A relative redirect from a server-function action should either navigate (soft or hard) or fail loudly — never silently do nothing while the cookie/data update succeeded.
@solidjs/server-functionstransport with single-flight enabled.