@solidjs/router 2.0.0-next.19 → 2.0.0-next.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { $TRACK, action as createSolidAction, createMemo, onCleanup, getOwner } from "solid-js";
2
2
  import { isResponseEnvelope, isServer, REVALIDATE_HEADER } from "@solidjs/web";
3
- import { createServerReference, decodeResponsePayload, parseServerFunctionUrl, subscribeFlightData } from "@solidjs/web/server-functions";
3
+ import { createServerReference, decodeRedirectHeaderValue, decodeResponsePayload, parseServerFunctionUrl, REDIRECT_HEADER, subscribeFlightData } from "@solidjs/web/server-functions";
4
4
  // The explicit /server specifier is safe here: the only call site is
5
5
  // server-guarded, so client builds tree-shake the codec away.
6
6
  import { decodeFlashCookie } from "@solidjs/web/server-functions/server";
@@ -68,11 +68,13 @@ export function handleFormAction(evt, router, actionBase) {
68
68
  * carries everything an invocation needs — the function id in the path
69
69
  * (`<endpoint>/<id>`) and any bound `.with()` arguments (plain JSON in
70
70
  * `?args`, which the server prepends for natural-encoding bodies exactly as
71
- * it does for no-JS posts) — so the FormData is posted to it verbatim
72
- * through the server-function transport:
73
- * submissions, `aria-busy`, redirects, revalidation, and single-flight all
74
- * flow through the normal action machinery. Registered under the url, so
75
- * repeat submits reuse it (and a later real registration overrides it).
71
+ * it does for no-JS posts) — so the FormData is posted through the
72
+ * server-function transport, which addresses its own scripted calls at the
73
+ * url's data sibling (`<endpoint>/data/<id>`, solidjs/solid#3094) with the
74
+ * bound query intact: submissions, `aria-busy`, redirects, revalidation,
75
+ * and single-flight all flow through the normal action machinery.
76
+ * Registered under the rendered url, so repeat submits reuse it (and a
77
+ * later real registration overrides it).
76
78
  */
77
79
  function createServerFormAction(url) {
78
80
  const id = parseServerFunctionUrl(url);
@@ -290,6 +292,10 @@ async function settleActionResult(result) {
290
292
  // response's metadata (and so the default revalidation pass must not run
291
293
  // again and wipe the freshly seeded cache).
292
294
  let flightApplications = 0;
295
+ // The statuses fetch follows (Fetch §2.2.3) — the set the server masks into
296
+ // the redirect carrier for scripted calls, and the set a locally-produced
297
+ // redirect() envelope wears for real.
298
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
293
299
  /**
294
300
  * Registers the router as the single-flight consumer of the server function
295
301
  * transport. Subscribing is the opt-in: while registered, the transport
@@ -309,23 +315,43 @@ export function setupFlightDataConsumer(router) {
309
315
  }
310
316
  /**
311
317
  * Applies a server function response's integration metadata: `X-Revalidate`
312
- * keys invalidate, `Location` navigates (hard for absolute urls), flight
313
- * data seeds the query cache, and matching entries revalidate. Shared by
314
- * the flight-data consumer and the action response path (which still sees
315
- * metadata-bearing responses when no flight data was collected).
318
+ * keys invalidate, the redirect carrier navigates (soft when same-origin,
319
+ * hard otherwise), flight data seeds the query cache, and matching entries
320
+ * revalidate. Shared by the flight-data consumer and the action response
321
+ * path (which still sees metadata-bearing responses when no flight data was
322
+ * collected).
316
323
  */
317
324
  function applyResponseMetadata(metadata, navigate, flightData) {
318
325
  let keys;
319
326
  if (metadata) {
320
327
  if (metadata.headers.has(REVALIDATE_HEADER))
321
328
  keys = metadata.headers.get(REVALIDATE_HEADER).split(",");
322
- if (metadata.headers.has("Location")) {
323
- const locationUrl = metadata.headers.get("Location") || "/";
324
- if (locationUrl.startsWith("http")) {
325
- window.location.href = locationUrl;
329
+ // The carrier delivers the target RESOLVED to an absolute url
330
+ // (solidjs/solid#3102), so the soft/hard split is a real origin
331
+ // comparison — never a guess from how the author spelled the target,
332
+ // which sent `redirect("/")` and `redirect(new URL("/", url).href)`
333
+ // down different navigation paths (solidjs/solid#3107). A redirect
334
+ // produced locally (a client-side action's `redirect()`) never crossed
335
+ // the wire, so no carrier was attached: it is the real 3xx with its
336
+ // Location, resolved against the page it runs in. A `Location` on any
337
+ // other status is the author's data (a 201's created-at) and never
338
+ // navigates. Same-origin targets navigate softly under the router;
339
+ // anything else leaves the app, so the document goes with it.
340
+ // `replace` matches what HTTP gives a form post: the target takes the
341
+ // submission's place in history rather than stacking on it.
342
+ const carried = decodeRedirectHeaderValue(metadata.headers.get(REDIRECT_HEADER));
343
+ const local = !carried && REDIRECT_STATUSES.has(metadata.status) && metadata.headers.get("Location");
344
+ const target = carried
345
+ ? new URL(carried.url)
346
+ : local
347
+ ? new URL(local, window.location.href)
348
+ : undefined;
349
+ if (target) {
350
+ if (target.origin === window.location.origin) {
351
+ navigate(target.pathname + target.search + target.hash, { replace: true });
326
352
  }
327
353
  else {
328
- navigate(locationUrl);
354
+ window.location.href = target.href;
329
355
  }
330
356
  }
331
357
  }
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { getOwner, runWithOwner, createMemo, createRenderEffect, onCleanup, untrack, createContext, createSignal, useContext, NotReadyError, isPending, DEV, flush, createComponent, createRoot, Show, createEffect, sharedConfig, getObserver, $TRACK, action as action$1 } from 'solid-js';
1
+ import { getOwner, runWithOwner, createMemo, createRenderEffect, onCleanup, untrack, createContext, createSignal, useContext, NotReadyError, isPending, DEV, latest, createComponent, createRoot, Show, createEffect, sharedConfig, onSettled, getObserver, $TRACK, action as action$1 } from 'solid-js';
2
2
  import { registerElementClaim, delegateEvents, isServer, getRequestEvent, hasFlashCookie, clearFlashCookie, createComponent as createComponent$1, memo, isServerFunction, getServerFunctionMetadata, getServerFunctionRPC, isResponseEnvelope, REVALIDATE_HEADER } from '@solidjs/web';
3
- import { subscribeFlightData, decodeResponsePayload, parseServerFunctionUrl, createServerReference } from '@solidjs/web/server-functions';
3
+ import { subscribeFlightData, decodeRedirectHeaderValue, REDIRECT_HEADER, decodeResponsePayload, parseServerFunctionUrl, createServerReference } from '@solidjs/web/server-functions';
4
4
  import { decodeFlashCookie } from '@solidjs/web/server-functions/server';
5
5
 
6
6
  const hasSchemeRegex = /^(?:[a-z0-9]+:)?\/\//i;
@@ -844,18 +844,15 @@ function getLazyBoundary(thunk) {
844
844
  * synchronously once available, the in-flight promise otherwise.
845
845
  *
846
846
  * Failure contract (same as solid's lazy(), 2.0.0-rc.1: the platform
847
- * re-fetches a failed dynamic import): a client rejection is held through its
848
- * settlement flush. The recomputes the settled promise triggers — the parked
849
- * transition's and the mainline commit's — both consume it here as a
850
- * synchronous throw, becoming cached error status that reaches the nearest
851
- * error boundary like a failed lazy() component. The hold clears a microtask
852
- * after the first delivery, so any later recompute — boundary reset(), a new
853
- * navigation — finds a clean record and retries the import. Holding through
854
- * the flush is what keeps the erroring computation from refiring the import
855
- * in a tight loop while it fails. Server records are shared across requests
856
- * and hold nothing — each request retries. Commit is always async — even for
857
- * thunks returning arrays — so the version bump never writes a signal from
858
- * inside a render computation.
847
+ * re-fetches a failed dynamic import): each client router's async boundary
848
+ * reader retains the rejection as reactive error status, so the destination
849
+ * error boundary receives it without refiring the import. The shared record
850
+ * holds that rejection through the current turn for concurrent readers, then
851
+ * clears so a later tracked retry — boundary reset() or a new navigation —
852
+ * starts a fresh import. Server records are shared across requests and hold
853
+ * nothing, so each request retries. Commit is always async — even for thunks
854
+ * returning arrays — so the version bump never writes a signal from inside a
855
+ * render computation.
859
856
  */
860
857
  function resolveLazySubtree(record) {
861
858
  if (record.resolved) return record.resolved;
@@ -874,7 +871,15 @@ function resolveLazySubtree(record) {
874
871
  return record.resolved;
875
872
  }, e => {
876
873
  // ?? Error(): a held undefined would read as "no failure" and refire
877
- if (!isServer) record.error = e ?? new Error();
874
+ if (!isServer) {
875
+ record.error = e ?? new Error();
876
+ record.sweep = true;
877
+ // Existing per-router async readers retain the rejection in their
878
+ // reactive error status. Hold the shared record through this turn so
879
+ // concurrent readers receive the same failure, then permit remounts
880
+ // and boundary resets to create a fresh import attempt.
881
+ queueMicrotask(() => record.error = record.sweep = undefined);
882
+ }
878
883
  record.promise = undefined;
879
884
  throw e;
880
885
  });
@@ -1097,9 +1102,9 @@ let flashDecoder;
1097
1102
  function provideFlashDecoder(decoder) {
1098
1103
  flashDecoder || (flashDecoder = decoder);
1099
1104
  }
1100
- let intent;
1105
+ let preloadIntent;
1101
1106
  function getIntent() {
1102
- return intent;
1107
+ return preloadIntent || useOptionalContext(RouterContextObj)?.intent?.();
1103
1108
  }
1104
1109
  let inPreloadFn = false;
1105
1110
  function getInPreloadFn() {
@@ -1125,26 +1130,11 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1125
1130
  setSource({
1126
1131
  value: basePath,
1127
1132
  replace: true,
1128
- scroll: false
1133
+ scroll: false,
1134
+ _navigation: isServer ? undefined : 1
1129
1135
  });
1130
1136
  }
1131
- const [isNavigating, setIsRouting] = createSignal(false, {
1132
- ownedWrite: true
1133
- });
1134
-
1135
- // Navigate override written from event handlers.
1136
- const [navigateTarget, setNavigateTarget] = createSignal(undefined, {
1137
- ownedWrite: true
1138
- });
1139
-
1140
- // Keep track of last target, so that last call to navigate wins
1141
- let lastTransitionTarget;
1142
-
1143
- // source() remains canonical for native history changes; navigateTarget()
1144
- // temporarily overrides it for in-flight programmatic navigation.
1145
- const effective = createMemo(() => navigateTarget() ?? source());
1146
- const location = createLocation(() => effective().value, () => effective().state, utils.queryWrapper);
1147
- const referrers = [];
1137
+ const location = createLocation(() => source().value, () => source().state, utils.queryWrapper);
1148
1138
  // The flash cookie is consumed eagerly: its one-shot clear (Set-Cookie)
1149
1139
  // must be appended before streaming flushes the response headers, and an
1150
1140
  // unread outcome must not haunt a later request's render. Only detection
@@ -1164,6 +1154,24 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1164
1154
  }
1165
1155
  }
1166
1156
  let submissions;
1157
+
1158
+ // NotReadyError's source must be a reactive async node, not the raw
1159
+ // Promise. Keep one reader per boundary for this router owner so rejection
1160
+ // is delivered through the graph's error channel and resolution wakes the
1161
+ // parked matches computation.
1162
+ const routerOwner = getOwner();
1163
+ const lazyReaders = new WeakMap();
1164
+ const readLazySubtree = record => {
1165
+ let read = lazyReaders.get(record);
1166
+ if (!read) {
1167
+ read = runWithOwner(routerOwner, () => createMemo(() => {
1168
+ const result = resolveLazySubtree(record);
1169
+ return result instanceof Promise ? result.then(() => undefined) : undefined;
1170
+ }));
1171
+ lazyReaders.set(record, read);
1172
+ }
1173
+ return read();
1174
+ };
1167
1175
  const matches = createMemo(() => {
1168
1176
  const pathname = typeof options.transformUrl === "function" ? options.transformUrl(location.pathname) : location.pathname;
1169
1177
  const m = getRouteMatches(branches(), pathname);
@@ -1176,26 +1184,22 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1176
1184
  // inside a boundary just parks the recomputed chain again.
1177
1185
  const pending = unresolvedLazyMatches(m);
1178
1186
  if (pending.length) {
1179
- const all = Promise.all(pending.map(resolveLazySubtree));
1180
- // pre-handle rejections: the failure reaches the app through this
1181
- // computation's error status, so the raw chain must not also surface
1182
- // as an unhandled rejection
1183
- all.catch(() => {});
1184
- throw new NotReadyError(all);
1187
+ if (isServer) {
1188
+ // SSR carries the Promise through NotReadyError so the streaming
1189
+ // renderer can resume without attempting to serialize route
1190
+ // definitions (which contain component functions).
1191
+ const all = Promise.all(pending.map(resolveLazySubtree));
1192
+ all.catch(() => {});
1193
+ throw new NotReadyError(all);
1194
+ } else {
1195
+ // On the client the source must be a reactive async node so transition
1196
+ // settlement and rejection delivery remain inside the signals graph.
1197
+ for (const boundary of pending) readLazySubtree(boundary);
1198
+ }
1185
1199
  }
1186
1200
  return m;
1187
1201
  });
1188
-
1189
- // Every write is a transition in Solid 2, so a native history pop forks the
1190
- // source signal exactly like programmatic navigation does. isRouting is
1191
- // therefore derived: the manual flag covers navigateFromRoute's explicit
1192
- // window, and isPending over the location/matches read reports any
1193
- // in-flight fork — including popstate traversals and the lazy-subtree
1194
- // resolution matches() parks on.
1195
- const isRouting = createMemo(() => isNavigating() || isPending(() => {
1196
- // A real error means the navigation settled (failed) — it surfaces
1197
- // to the app through render reads, not through this derivation.
1198
- // Not-ready must keep propagating so isPending sees the parking.
1202
+ const routingPending = createMemo(() => isPending(() => {
1199
1203
  try {
1200
1204
  matches();
1201
1205
  } catch (e) {
@@ -1204,6 +1208,17 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1204
1208
  location.search;
1205
1209
  location.hash;
1206
1210
  }));
1211
+ const isRouting = () => routingPending() || isPending(source);
1212
+ const transitionIntent = () => {
1213
+ if (!isPending(source)) return;
1214
+ const navigation = latest(source)._navigation;
1215
+ return navigation === -1 ? "native" : navigation && navigation > 0 ? "navigate" : undefined;
1216
+ };
1217
+ const pendingNavigation = () => {
1218
+ if (!isRouting()) return;
1219
+ const target = latest(source);
1220
+ return target._navigation && target._navigation > 0 ? target : undefined;
1221
+ };
1207
1222
  const buildParams = () => mergeParams(matches());
1208
1223
  const wrapParams = utils.paramsWrapper ? getParams => utils.paramsWrapper(getParams, branches) : getParams => createMemoObject(getParams);
1209
1224
  const params = wrapParams(buildParams);
@@ -1222,8 +1237,9 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1222
1237
  params,
1223
1238
  wrapParams,
1224
1239
  isRouting,
1240
+ intent: transitionIntent,
1225
1241
  get pendingTarget() {
1226
- return lastTransitionTarget;
1242
+ return pendingNavigation();
1227
1243
  },
1228
1244
  renderPath,
1229
1245
  parsePath,
@@ -1285,11 +1301,13 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1285
1301
  }
1286
1302
  if (resolvedTo === undefined) {
1287
1303
  throw new Error(`Path '${to}' is not a routable path`);
1288
- } else if (referrers.length >= MAX_REDIRECTS) {
1304
+ }
1305
+ const headed = latest(source);
1306
+ const navigationDepth = !isServer && isPending(source) && headed._navigation !== undefined && headed._navigation > 0 ? headed._navigation : 0;
1307
+ if (navigationDepth >= MAX_REDIRECTS) {
1289
1308
  throw new Error("Too many redirects");
1290
1309
  }
1291
- const current = effective();
1292
- if (resolvedTo !== current.value || nextState !== current.state) {
1310
+ if (resolvedTo !== headed.value || nextState !== headed.state) {
1293
1311
  if (isServer) {
1294
1312
  const e = getRequestEvent();
1295
1313
  e && (e.response = {
@@ -1305,38 +1323,13 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1305
1323
  state: nextState
1306
1324
  });
1307
1325
  } else if (!beforeLeave.current || beforeLeave.current.confirm(resolvedTo, options)) {
1308
- referrers.push({
1309
- value: current.value,
1310
- replace,
1311
- scroll,
1312
- state: current.state
1313
- });
1314
- const newTarget = {
1326
+ runWithOwner(null, () => setSource({
1315
1327
  value: resolvedTo,
1316
- state: nextState
1317
- };
1318
- const firstNavigation = lastTransitionTarget === undefined;
1319
- intent = "navigate";
1320
- // assign the target before flushing so effects that run for the
1321
- // isRouting flip (e.g. pending link state) can read it
1322
- lastTransitionTarget = newTarget;
1323
- if (firstNavigation) {
1324
- setIsRouting(true);
1325
- flush();
1326
- }
1327
- if (lastTransitionTarget === newTarget) {
1328
- setNavigateTarget({
1329
- ...lastTransitionTarget
1330
- });
1331
- queueMicrotask(() => {
1332
- if (lastTransitionTarget !== newTarget) return;
1333
- intent = undefined;
1334
- navigateEnd(lastTransitionTarget);
1335
- setNavigateTarget(undefined);
1336
- setIsRouting(false);
1337
- lastTransitionTarget = undefined;
1338
- });
1339
- }
1328
+ state: nextState,
1329
+ replace: navigationDepth ? headed.replace : replace,
1330
+ scroll: navigationDepth ? headed.scroll : scroll,
1331
+ _navigation: navigationDepth + 1
1332
+ }));
1340
1333
  }
1341
1334
  }
1342
1335
  });
@@ -1346,17 +1339,6 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1346
1339
  route = route || useOptionalContext(RouteContextObj) || baseRoute;
1347
1340
  return (to, options) => navigateFromRoute(route, to, options);
1348
1341
  }
1349
- function navigateEnd(next) {
1350
- const first = referrers[0];
1351
- if (first) {
1352
- setSource({
1353
- ...next,
1354
- replace: first.replace,
1355
- scroll: first.scroll
1356
- });
1357
- referrers.length = 0;
1358
- }
1359
- }
1360
1342
  function preloadRoute(url, preloadData) {
1361
1343
  const matches = getRouteMatches(branches(), url.pathname);
1362
1344
  // An unresolved lazy subtree in the chain: the placeholder's
@@ -1370,8 +1352,8 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1370
1352
  resolveLazySubtree(boundary.route.lazy).then(() => preloadRoute(url, preloadData), () => {});
1371
1353
  } catch {}
1372
1354
  }
1373
- const prevIntent = intent;
1374
- intent = "preload";
1355
+ const prevIntent = preloadIntent;
1356
+ preloadIntent = "preload";
1375
1357
  for (let match in matches) {
1376
1358
  const {
1377
1359
  route,
@@ -1396,7 +1378,7 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1396
1378
  }));
1397
1379
  inPreloadFn = false;
1398
1380
  }
1399
- intent = prevIntent;
1381
+ preloadIntent = prevIntent;
1400
1382
  }
1401
1383
 
1402
1384
  // Seeds the initial submission from a no-JS form post: the server
@@ -1437,7 +1419,7 @@ function createRouteContext(router, parent, outlet, match, matches = () => [matc
1437
1419
  const data = preload ? preload({
1438
1420
  params,
1439
1421
  location,
1440
- intent: intent || "initial"
1422
+ intent: router.intent?.() || "initial"
1441
1423
  }) : undefined;
1442
1424
  inPreloadFn = false;
1443
1425
  const route = {
@@ -1985,23 +1967,37 @@ function defineRoute(route) {
1985
1967
  }
1986
1968
  /** Wraps a history adapter in the integration signal the router core consumes. Must run under a reactive owner. */
1987
1969
  function createIntegration(history) {
1988
- let ignore = false;
1970
+ let committing = false;
1989
1971
  const wrap = value => typeof value === "string" ? {
1990
1972
  value
1991
1973
  } : value;
1992
1974
  const [read, write] = createSignal(wrap(history.get()), {
1993
- equals: (a, b) => a.value === b.value && a.state === b.state,
1975
+ equals: (a, b) => a.value === b.value && a.state === b.state && a._navigation === b._navigation,
1994
1976
  ownedWrite: true
1995
1977
  });
1996
1978
  const signal = [read, next => {
1997
- !ignore && history.set(next);
1998
1979
  if (sharedConfig.registry && !sharedConfig.done) sharedConfig.done = true;
1999
1980
  write(next);
1981
+ if (next._navigation && next._navigation > 0) {
1982
+ // Register out of band so a destination error boundary replacing the
1983
+ // Router subtree cannot suppress the winning history commit.
1984
+ runWithOwner(null, () => onSettled(() => {
1985
+ if (read() !== next) return;
1986
+ committing = true;
1987
+ try {
1988
+ history.set(next);
1989
+ } finally {
1990
+ committing = false;
1991
+ }
1992
+ }));
1993
+ }
2000
1994
  }];
2001
1995
  history.init && onCleanup(history.init((value = history.get()) => {
2002
- ignore = true;
2003
- signal[1](wrap(value));
2004
- ignore = false;
1996
+ if (committing) return;
1997
+ signal[1]({
1998
+ ...wrap(value),
1999
+ _navigation: -1
2000
+ });
2005
2001
  }));
2006
2002
  return {
2007
2003
  signal,
@@ -2566,11 +2562,13 @@ function handleFormAction(evt, router, actionBase) {
2566
2562
  * carries everything an invocation needs — the function id in the path
2567
2563
  * (`<endpoint>/<id>`) and any bound `.with()` arguments (plain JSON in
2568
2564
  * `?args`, which the server prepends for natural-encoding bodies exactly as
2569
- * it does for no-JS posts) — so the FormData is posted to it verbatim
2570
- * through the server-function transport:
2571
- * submissions, `aria-busy`, redirects, revalidation, and single-flight all
2572
- * flow through the normal action machinery. Registered under the url, so
2573
- * repeat submits reuse it (and a later real registration overrides it).
2565
+ * it does for no-JS posts) — so the FormData is posted through the
2566
+ * server-function transport, which addresses its own scripted calls at the
2567
+ * url's data sibling (`<endpoint>/data/<id>`, solidjs/solid#3094) with the
2568
+ * bound query intact: submissions, `aria-busy`, redirects, revalidation,
2569
+ * and single-flight all flow through the normal action machinery.
2570
+ * Registered under the rendered url, so repeat submits reuse it (and a
2571
+ * later real registration overrides it).
2574
2572
  */
2575
2573
  function createServerFormAction(url) {
2576
2574
  const id = parseServerFunctionUrl(url);
@@ -2795,6 +2793,11 @@ async function settleActionResult(result) {
2795
2793
  // again and wipe the freshly seeded cache).
2796
2794
  let flightApplications = 0;
2797
2795
 
2796
+ // The statuses fetch follows (Fetch §2.2.3) — the set the server masks into
2797
+ // the redirect carrier for scripted calls, and the set a locally-produced
2798
+ // redirect() envelope wears for real.
2799
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
2800
+
2798
2801
  /**
2799
2802
  * Registers the router as the single-flight consumer of the server function
2800
2803
  * transport. Subscribing is the opt-in: while registered, the transport
@@ -2817,21 +2820,39 @@ function setupFlightDataConsumer(router) {
2817
2820
 
2818
2821
  /**
2819
2822
  * Applies a server function response's integration metadata: `X-Revalidate`
2820
- * keys invalidate, `Location` navigates (hard for absolute urls), flight
2821
- * data seeds the query cache, and matching entries revalidate. Shared by
2822
- * the flight-data consumer and the action response path (which still sees
2823
- * metadata-bearing responses when no flight data was collected).
2823
+ * keys invalidate, the redirect carrier navigates (soft when same-origin,
2824
+ * hard otherwise), flight data seeds the query cache, and matching entries
2825
+ * revalidate. Shared by the flight-data consumer and the action response
2826
+ * path (which still sees metadata-bearing responses when no flight data was
2827
+ * collected).
2824
2828
  */
2825
2829
  function applyResponseMetadata(metadata, navigate, flightData) {
2826
2830
  let keys;
2827
2831
  if (metadata) {
2828
2832
  if (metadata.headers.has(REVALIDATE_HEADER)) keys = metadata.headers.get(REVALIDATE_HEADER).split(",");
2829
- if (metadata.headers.has("Location")) {
2830
- const locationUrl = metadata.headers.get("Location") || "/";
2831
- if (locationUrl.startsWith("http")) {
2832
- window.location.href = locationUrl;
2833
+ // The carrier delivers the target RESOLVED to an absolute url
2834
+ // (solidjs/solid#3102), so the soft/hard split is a real origin
2835
+ // comparison — never a guess from how the author spelled the target,
2836
+ // which sent `redirect("/")` and `redirect(new URL("/", url).href)`
2837
+ // down different navigation paths (solidjs/solid#3107). A redirect
2838
+ // produced locally (a client-side action's `redirect()`) never crossed
2839
+ // the wire, so no carrier was attached: it is the real 3xx with its
2840
+ // Location, resolved against the page it runs in. A `Location` on any
2841
+ // other status is the author's data (a 201's created-at) and never
2842
+ // navigates. Same-origin targets navigate softly under the router;
2843
+ // anything else leaves the app, so the document goes with it.
2844
+ // `replace` matches what HTTP gives a form post: the target takes the
2845
+ // submission's place in history rather than stacking on it.
2846
+ const carried = decodeRedirectHeaderValue(metadata.headers.get(REDIRECT_HEADER));
2847
+ const local = !carried && REDIRECT_STATUSES.has(metadata.status) && metadata.headers.get("Location");
2848
+ const target = carried ? new URL(carried.url) : local ? new URL(local, window.location.href) : undefined;
2849
+ if (target) {
2850
+ if (target.origin === window.location.origin) {
2851
+ navigate(target.pathname + target.search + target.hash, {
2852
+ replace: true
2853
+ });
2833
2854
  } else {
2834
- navigate(locationUrl);
2855
+ window.location.href = target.href;
2835
2856
  }
2836
2857
  }
2837
2858
  }
@@ -1,5 +1,5 @@
1
1
  /*@refresh skip*/
2
- import { createSignal, getOwner, onCleanup, sharedConfig, untrack } from "solid-js";
2
+ import { createSignal, getOwner, onCleanup, onSettled, runWithOwner, sharedConfig, untrack } from "solid-js";
3
3
  // standalone import: `DEV` is undefined in solid's production build, so app
4
4
  // bundlers fold `DEV &&` diagnostics out of shipped bundles
5
5
  import { DEV } from "solid-js";
@@ -27,26 +27,40 @@ export function defineRoute(route) {
27
27
  }
28
28
  /** Wraps a history adapter in the integration signal the router core consumes. Must run under a reactive owner. */
29
29
  function createIntegration(history) {
30
- let ignore = false;
30
+ let committing = false;
31
31
  const wrap = (value) => (typeof value === "string" ? { value } : value);
32
32
  const [read, write] = createSignal(wrap(history.get()), {
33
- equals: (a, b) => a.value === b.value && a.state === b.state,
33
+ equals: (a, b) => a.value === b.value && a.state === b.state && a._navigation === b._navigation,
34
34
  ownedWrite: true
35
35
  });
36
36
  const signal = [
37
37
  read,
38
38
  (next) => {
39
- !ignore && history.set(next);
40
39
  if (sharedConfig.registry && !sharedConfig.done)
41
40
  sharedConfig.done = true;
42
41
  write(next);
42
+ if (next._navigation && next._navigation > 0) {
43
+ // Register out of band so a destination error boundary replacing the
44
+ // Router subtree cannot suppress the winning history commit.
45
+ runWithOwner(null, () => onSettled(() => {
46
+ if (read() !== next)
47
+ return;
48
+ committing = true;
49
+ try {
50
+ history.set(next);
51
+ }
52
+ finally {
53
+ committing = false;
54
+ }
55
+ }));
56
+ }
43
57
  }
44
58
  ];
45
59
  history.init &&
46
60
  onCleanup(history.init((value = history.get()) => {
47
- ignore = true;
48
- signal[1](wrap(value));
49
- ignore = false;
61
+ if (committing)
62
+ return;
63
+ signal[1]({ ...wrap(value), _navigation: -1 });
50
64
  }));
51
65
  return { signal, utils: history.utils };
52
66
  }
package/dist/routing.d.ts CHANGED
@@ -192,18 +192,15 @@ export declare function peekLazySubtrees(): number;
192
192
  * synchronously once available, the in-flight promise otherwise.
193
193
  *
194
194
  * Failure contract (same as solid's lazy(), 2.0.0-rc.1: the platform
195
- * re-fetches a failed dynamic import): a client rejection is held through its
196
- * settlement flush. The recomputes the settled promise triggers — the parked
197
- * transition's and the mainline commit's — both consume it here as a
198
- * synchronous throw, becoming cached error status that reaches the nearest
199
- * error boundary like a failed lazy() component. The hold clears a microtask
200
- * after the first delivery, so any later recompute — boundary reset(), a new
201
- * navigation — finds a clean record and retries the import. Holding through
202
- * the flush is what keeps the erroring computation from refiring the import
203
- * in a tight loop while it fails. Server records are shared across requests
204
- * and hold nothing — each request retries. Commit is always async — even for
205
- * thunks returning arrays — so the version bump never writes a signal from
206
- * inside a render computation.
195
+ * re-fetches a failed dynamic import): each client router's async boundary
196
+ * reader retains the rejection as reactive error status, so the destination
197
+ * error boundary receives it without refiring the import. The shared record
198
+ * holds that rejection through the current turn for concurrent readers, then
199
+ * clears so a later tracked retry — boundary reset() or a new navigation —
200
+ * starts a fresh import. Server records are shared across requests and hold
201
+ * nothing, so each request retries. Commit is always async — even for thunks
202
+ * returning arrays — so the version bump never writes a signal from inside a
203
+ * render computation.
207
204
  */
208
205
  export declare function resolveLazySubtree(record: LazyBoundary): readonly RouteDefinition[] | Promise<readonly RouteDefinition[]>;
209
206
  /**
package/dist/routing.js CHANGED
@@ -1,8 +1,8 @@
1
- import { flush, runWithOwner } from "solid-js";
1
+ import { runWithOwner } from "solid-js";
2
2
  // standalone import: `DEV` is undefined in solid's production build, so app
3
3
  // bundlers fold `DEV &&` diagnostics out of shipped bundles
4
4
  import { DEV } from "solid-js";
5
- import { createComponent, createContext, createMemo, createSignal, isPending, NotReadyError, untrack, useContext } from "solid-js";
5
+ import { createComponent, createContext, createMemo, createSignal, getOwner, isPending, latest, NotReadyError, untrack, useContext } from "solid-js";
6
6
  import { clearFlashCookie, getRequestEvent, hasFlashCookie, isServer } from "@solidjs/web";
7
7
  import { mockBase, comparablePath, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals } from "./utils.js";
8
8
  import { HREF } from "./paths.js";
@@ -289,18 +289,15 @@ function getLazyBoundary(thunk) {
289
289
  * synchronously once available, the in-flight promise otherwise.
290
290
  *
291
291
  * Failure contract (same as solid's lazy(), 2.0.0-rc.1: the platform
292
- * re-fetches a failed dynamic import): a client rejection is held through its
293
- * settlement flush. The recomputes the settled promise triggers — the parked
294
- * transition's and the mainline commit's — both consume it here as a
295
- * synchronous throw, becoming cached error status that reaches the nearest
296
- * error boundary like a failed lazy() component. The hold clears a microtask
297
- * after the first delivery, so any later recompute — boundary reset(), a new
298
- * navigation — finds a clean record and retries the import. Holding through
299
- * the flush is what keeps the erroring computation from refiring the import
300
- * in a tight loop while it fails. Server records are shared across requests
301
- * and hold nothing — each request retries. Commit is always async — even for
302
- * thunks returning arrays — so the version bump never writes a signal from
303
- * inside a render computation.
292
+ * re-fetches a failed dynamic import): each client router's async boundary
293
+ * reader retains the rejection as reactive error status, so the destination
294
+ * error boundary receives it without refiring the import. The shared record
295
+ * holds that rejection through the current turn for concurrent readers, then
296
+ * clears so a later tracked retry — boundary reset() or a new navigation —
297
+ * starts a fresh import. Server records are shared across requests and hold
298
+ * nothing, so each request retries. Commit is always async — even for thunks
299
+ * returning arrays — so the version bump never writes a signal from inside a
300
+ * render computation.
304
301
  */
305
302
  export function resolveLazySubtree(record) {
306
303
  if (record.resolved)
@@ -325,8 +322,15 @@ export function resolveLazySubtree(record) {
325
322
  return record.resolved;
326
323
  }, e => {
327
324
  // ?? Error(): a held undefined would read as "no failure" and refire
328
- if (!isServer)
325
+ if (!isServer) {
329
326
  record.error = e ?? new Error();
327
+ record.sweep = true;
328
+ // Existing per-router async readers retain the rejection in their
329
+ // reactive error status. Hold the shared record through this turn so
330
+ // concurrent readers receive the same failure, then permit remounts
331
+ // and boundary resets to create a fresh import attempt.
332
+ queueMicrotask(() => (record.error = record.sweep = undefined));
333
+ }
330
334
  record.promise = undefined;
331
335
  throw e;
332
336
  }));
@@ -551,9 +555,9 @@ let flashDecoder;
551
555
  export function provideFlashDecoder(decoder) {
552
556
  flashDecoder || (flashDecoder = decoder);
553
557
  }
554
- let intent;
558
+ let preloadIntent;
555
559
  export function getIntent() {
556
- return intent;
560
+ return preloadIntent || useOptionalContext(RouterContextObj)?.intent?.();
557
561
  }
558
562
  let inPreloadFn = false;
559
563
  export function getInPreloadFn() {
@@ -574,20 +578,14 @@ export function createRouterContext(integration, branches, getContext, options =
574
578
  throw new Error(`${basePath} is not a valid base path`);
575
579
  }
576
580
  else if (basePath && !initialSource.value) {
577
- setSource({ value: basePath, replace: true, scroll: false });
581
+ setSource({
582
+ value: basePath,
583
+ replace: true,
584
+ scroll: false,
585
+ _navigation: isServer ? undefined : 1
586
+ });
578
587
  }
579
- const [isNavigating, setIsRouting] = createSignal(false, { ownedWrite: true });
580
- // Navigate override written from event handlers.
581
- const [navigateTarget, setNavigateTarget] = createSignal(undefined, {
582
- ownedWrite: true
583
- });
584
- // Keep track of last target, so that last call to navigate wins
585
- let lastTransitionTarget;
586
- // source() remains canonical for native history changes; navigateTarget()
587
- // temporarily overrides it for in-flight programmatic navigation.
588
- const effective = createMemo(() => navigateTarget() ?? source());
589
- const location = createLocation(() => effective().value, () => effective().state, utils.queryWrapper);
590
- const referrers = [];
588
+ const location = createLocation(() => source().value, () => source().state, utils.queryWrapper);
591
589
  // The flash cookie is consumed eagerly: its one-shot clear (Set-Cookie)
592
590
  // must be appended before streaming flushes the response headers, and an
593
591
  // unread outcome must not haunt a later request's render. Only detection
@@ -608,6 +606,23 @@ export function createRouterContext(integration, branches, getContext, options =
608
606
  }
609
607
  }
610
608
  let submissions;
609
+ // NotReadyError's source must be a reactive async node, not the raw
610
+ // Promise. Keep one reader per boundary for this router owner so rejection
611
+ // is delivered through the graph's error channel and resolution wakes the
612
+ // parked matches computation.
613
+ const routerOwner = getOwner();
614
+ const lazyReaders = new WeakMap();
615
+ const readLazySubtree = (record) => {
616
+ let read = lazyReaders.get(record);
617
+ if (!read) {
618
+ read = runWithOwner(routerOwner, () => createMemo(() => {
619
+ const result = resolveLazySubtree(record);
620
+ return result instanceof Promise ? result.then(() => undefined) : undefined;
621
+ }));
622
+ lazyReaders.set(record, read);
623
+ }
624
+ return read();
625
+ };
611
626
  const matches = createMemo(() => {
612
627
  const pathname = typeof options.transformUrl === "function"
613
628
  ? options.transformUrl(location.pathname)
@@ -622,36 +637,47 @@ export function createRouterContext(integration, branches, getContext, options =
622
637
  // inside a boundary just parks the recomputed chain again.
623
638
  const pending = unresolvedLazyMatches(m);
624
639
  if (pending.length) {
625
- const all = Promise.all(pending.map(resolveLazySubtree));
626
- // pre-handle rejections: the failure reaches the app through this
627
- // computation's error status, so the raw chain must not also surface
628
- // as an unhandled rejection
629
- all.catch(() => { });
630
- throw new NotReadyError(all);
640
+ if (isServer) {
641
+ // SSR carries the Promise through NotReadyError so the streaming
642
+ // renderer can resume without attempting to serialize route
643
+ // definitions (which contain component functions).
644
+ const all = Promise.all(pending.map(resolveLazySubtree));
645
+ all.catch(() => { });
646
+ throw new NotReadyError(all);
647
+ }
648
+ else {
649
+ // On the client the source must be a reactive async node so transition
650
+ // settlement and rejection delivery remain inside the signals graph.
651
+ for (const boundary of pending)
652
+ readLazySubtree(boundary);
653
+ }
631
654
  }
632
655
  return m;
633
656
  });
634
- // Every write is a transition in Solid 2, so a native history pop forks the
635
- // source signal exactly like programmatic navigation does. isRouting is
636
- // therefore derived: the manual flag covers navigateFromRoute's explicit
637
- // window, and isPending over the location/matches read reports any
638
- // in-flight fork — including popstate traversals and the lazy-subtree
639
- // resolution matches() parks on.
640
- const isRouting = createMemo(() => isNavigating() ||
641
- isPending(() => {
642
- // A real error means the navigation settled (failed) — it surfaces
643
- // to the app through render reads, not through this derivation.
644
- // Not-ready must keep propagating so isPending sees the parking.
645
- try {
646
- matches();
647
- }
648
- catch (e) {
649
- if (e instanceof NotReadyError)
650
- throw e;
651
- }
652
- location.search;
653
- location.hash;
654
- }));
657
+ const routingPending = createMemo(() => isPending(() => {
658
+ try {
659
+ matches();
660
+ }
661
+ catch (e) {
662
+ if (e instanceof NotReadyError)
663
+ throw e;
664
+ }
665
+ location.search;
666
+ location.hash;
667
+ }));
668
+ const isRouting = () => routingPending() || isPending(source);
669
+ const transitionIntent = () => {
670
+ if (!isPending(source))
671
+ return;
672
+ const navigation = latest(source)._navigation;
673
+ return navigation === -1 ? "native" : navigation && navigation > 0 ? "navigate" : undefined;
674
+ };
675
+ const pendingNavigation = () => {
676
+ if (!isRouting())
677
+ return;
678
+ const target = latest(source);
679
+ return target._navigation && target._navigation > 0 ? target : undefined;
680
+ };
655
681
  const buildParams = () => mergeParams(matches());
656
682
  const wrapParams = utils.paramsWrapper
657
683
  ? (getParams) => utils.paramsWrapper(getParams, branches)
@@ -672,8 +698,9 @@ export function createRouterContext(integration, branches, getContext, options =
672
698
  params,
673
699
  wrapParams,
674
700
  isRouting,
701
+ intent: transitionIntent,
675
702
  get pendingTarget() {
676
- return lastTransitionTarget;
703
+ return pendingNavigation();
677
704
  },
678
705
  renderPath,
679
706
  parsePath,
@@ -739,43 +766,30 @@ export function createRouterContext(integration, branches, getContext, options =
739
766
  if (resolvedTo === undefined) {
740
767
  throw new Error(`Path '${to}' is not a routable path`);
741
768
  }
742
- else if (referrers.length >= MAX_REDIRECTS) {
769
+ const headed = latest(source);
770
+ const navigationDepth = !isServer &&
771
+ isPending(source) &&
772
+ headed._navigation !== undefined &&
773
+ headed._navigation > 0
774
+ ? headed._navigation
775
+ : 0;
776
+ if (navigationDepth >= MAX_REDIRECTS) {
743
777
  throw new Error("Too many redirects");
744
778
  }
745
- const current = effective();
746
- if (resolvedTo !== current.value || nextState !== current.state) {
779
+ if (resolvedTo !== headed.value || nextState !== headed.state) {
747
780
  if (isServer) {
748
781
  const e = getRequestEvent();
749
782
  e && (e.response = { status: 302, headers: new Headers({ Location: resolvedTo }) });
750
783
  setSource({ value: resolvedTo, replace, scroll, state: nextState });
751
784
  }
752
785
  else if (!beforeLeave.current || beforeLeave.current.confirm(resolvedTo, options)) {
753
- referrers.push({ value: current.value, replace, scroll, state: current.state });
754
- const newTarget = {
786
+ runWithOwner(null, () => setSource({
755
787
  value: resolvedTo,
756
- state: nextState
757
- };
758
- const firstNavigation = lastTransitionTarget === undefined;
759
- intent = "navigate";
760
- // assign the target before flushing so effects that run for the
761
- // isRouting flip (e.g. pending link state) can read it
762
- lastTransitionTarget = newTarget;
763
- if (firstNavigation) {
764
- setIsRouting(true);
765
- flush();
766
- }
767
- if (lastTransitionTarget === newTarget) {
768
- setNavigateTarget({ ...lastTransitionTarget });
769
- queueMicrotask(() => {
770
- if (lastTransitionTarget !== newTarget)
771
- return;
772
- intent = undefined;
773
- navigateEnd(lastTransitionTarget);
774
- setNavigateTarget(undefined);
775
- setIsRouting(false);
776
- lastTransitionTarget = undefined;
777
- });
778
- }
788
+ state: nextState,
789
+ replace: navigationDepth ? headed.replace : replace,
790
+ scroll: navigationDepth ? headed.scroll : scroll,
791
+ _navigation: navigationDepth + 1
792
+ }));
779
793
  }
780
794
  }
781
795
  });
@@ -785,17 +799,6 @@ export function createRouterContext(integration, branches, getContext, options =
785
799
  route = route || useOptionalContext(RouteContextObj) || baseRoute;
786
800
  return (to, options) => navigateFromRoute(route, to, options);
787
801
  }
788
- function navigateEnd(next) {
789
- const first = referrers[0];
790
- if (first) {
791
- setSource({
792
- ...next,
793
- replace: first.replace,
794
- scroll: first.scroll
795
- });
796
- referrers.length = 0;
797
- }
798
- }
799
802
  function preloadRoute(url, preloadData) {
800
803
  const matches = getRouteMatches(branches(), url.pathname);
801
804
  // An unresolved lazy subtree in the chain: the placeholder's
@@ -810,8 +813,8 @@ export function createRouterContext(integration, branches, getContext, options =
810
813
  }
811
814
  catch { }
812
815
  }
813
- const prevIntent = intent;
814
- intent = "preload";
816
+ const prevIntent = preloadIntent;
817
+ preloadIntent = "preload";
815
818
  for (let match in matches) {
816
819
  const { route, params } = matches[match];
817
820
  route.component &&
@@ -835,7 +838,7 @@ export function createRouterContext(integration, branches, getContext, options =
835
838
  }));
836
839
  inPreloadFn = false;
837
840
  }
838
- intent = prevIntent;
841
+ preloadIntent = prevIntent;
839
842
  }
840
843
  // Seeds the initial submission from a no-JS form post: the server
841
844
  // function runtime redirected back with the outcome in a one-shot flash
@@ -872,7 +875,9 @@ export function createRouteContext(router, parent, outlet, match, matches = () =
872
875
  component.preload &&
873
876
  component.preload();
874
877
  inPreloadFn = true;
875
- const data = preload ? preload({ params, location, intent: intent || "initial" }) : undefined;
878
+ const data = preload
879
+ ? preload({ params, location, intent: router.intent?.() || "initial" })
880
+ : undefined;
876
881
  inPreloadFn = false;
877
882
  const route = {
878
883
  parent,
package/dist/types.d.ts CHANGED
@@ -97,6 +97,8 @@ export interface LocationChange<S = unknown> {
97
97
  scroll?: boolean;
98
98
  state?: S;
99
99
  rawPath?: string;
100
+ /** @internal Positive for programmatic navigation depth; `-1` for native history. */
101
+ _navigation?: number;
100
102
  }
101
103
  export interface RouterIntegration {
102
104
  signal: [get: () => LocationChange, set: (next: LocationChange) => void];
@@ -231,9 +233,9 @@ export interface LazyBoundary {
231
233
  thunk: LazyRouteChildren;
232
234
  promise?: Promise<readonly RouteDefinition[]>;
233
235
  resolved?: readonly RouteDefinition[];
234
- /** A client load failure, held through its settlement flush so every
235
- * recompute it triggers delivers the same error instead of refiring the
236
- * import; cleared a microtask after first delivery so retries can run. */
236
+ /** A client load failure, retained by each router's async reader and held
237
+ * here through the rejection turn for concurrent readers; then cleared so
238
+ * a later boundary reset or navigation can retry the import. */
237
239
  error?: unknown;
238
240
  /** The pending clear of `error` has been scheduled. */
239
241
  sweep?: boolean;
@@ -267,6 +269,8 @@ export interface RouterContext {
267
269
  wrapParams: (getParams: () => Params) => Params;
268
270
  navigatorFactory: NavigatorFactory;
269
271
  isRouting: () => boolean;
272
+ /** @internal Intent of the in-flight canonical location transition. */
273
+ intent?: () => Intent | undefined;
270
274
  /** The target of the in-flight navigation transition, if any. Not reactive. */
271
275
  readonly pendingTarget?: LocationChange;
272
276
  matches: () => RouteMatch[];
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "Ryan Turnquist"
7
7
  ],
8
8
  "license": "MIT",
9
- "version": "2.0.0-next.19",
9
+ "version": "2.0.0-next.21",
10
10
  "homepage": "https://github.com/solidjs/solid-router#readme",
11
11
  "repository": {
12
12
  "type": "git",
@@ -38,21 +38,21 @@
38
38
  "@rollup/plugin-node-resolve": "15.3.0",
39
39
  "@rollup/plugin-terser": "0.4.4",
40
40
  "@solidjs/vite-plugin": "3.0.0-next.35",
41
- "@solidjs/web": "^2.0.0-rc.4",
41
+ "@solidjs/web": "^2.0.0-rc.5",
42
42
  "@types/jest": "^29.5.14",
43
43
  "@types/node": "^22.10.0",
44
44
  "babel-preset-solid": "^2.0.0-rc.2",
45
45
  "jsdom": "^25.0.1",
46
46
  "prettier": "^3.4.1",
47
47
  "rollup": "^4.27.4",
48
- "solid-js": "^2.0.0-rc.4",
48
+ "solid-js": "^2.0.0-rc.5",
49
49
  "typescript": "^5.7.2",
50
50
  "vite": "^8.2.2",
51
51
  "vitest": "^4.1.11"
52
52
  },
53
53
  "peerDependencies": {
54
- "@solidjs/web": "^2.0.0-rc.4",
55
- "solid-js": "^2.0.0-rc.4"
54
+ "@solidjs/web": "^2.0.0-rc.5",
55
+ "solid-js": "^2.0.0-rc.5"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "rm -rf dist && tsc && rollup -c",