@solidjs/router 2.0.0-next.12 → 2.0.0-next.13

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.
@@ -330,7 +330,11 @@ function applyResponseMetadata(metadata, navigate, flightData) {
330
330
  cacheKeyOp(keys, entry => (entry[0] = 0));
331
331
  // set cache
332
332
  flightData && Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
333
- // trigger revalidation
333
+ // trigger revalidation inside the same transition as the navigation, so
334
+ // the redirect commits atomically with fresh data: surviving consumers
335
+ // (shared layouts) the flight payload didn't seed refetch and hold the
336
+ // commit rather than painting stale and updating after. Seeded entries
337
+ // are fresh again by now, so the sweep re-reads them from cache.
334
338
  revalidate(keys, false);
335
339
  }
336
340
  async function handleResponse(response, error, navigate, metadataHandled) {
@@ -1,7 +1,7 @@
1
1
  import { createSignal, getObserver, getOwner, onCleanup, sharedConfig } from "solid-js";
2
- import { getRequestEvent, isResponseEnvelope, isServer } from "@solidjs/web";
2
+ import { getRequestEvent, isResponseEnvelope, isServer, REVALIDATE_HEADER } from "@solidjs/web";
3
3
  import { GET, decodeResponse, getServerFunctionMetadata, isServerFunction } from "@solidjs/web/server-functions";
4
- import { useNavigate, getIntent, getInPreloadFn } from "../routing.js";
4
+ import { useRouter, useRoute, getIntent, getInPreloadFn } from "../routing.js";
5
5
  const LocationHeader = "Location";
6
6
  const PRELOAD_TIMEOUT = 5000;
7
7
  const CACHE_TIMEOUT = 180000;
@@ -58,7 +58,9 @@ export function query(fn, name) {
58
58
  const intent = getIntent();
59
59
  const inPreloadFn = getInPreloadFn();
60
60
  const owner = getOwner();
61
- const navigate = owner ? useNavigate() : undefined;
61
+ const router = owner ? useRouter() : undefined;
62
+ const route = owner ? useRoute() : undefined;
63
+ const navigate = router && router.navigatorFactory();
62
64
  const now = Date.now();
63
65
  const key = name + hashKey(args);
64
66
  let cached = cache.get(key);
@@ -82,17 +84,27 @@ export function query(fn, name) {
82
84
  tracking = true;
83
85
  onCleanup(() => cached[4].count--);
84
86
  }
87
+ // A read under a route section the in-flight navigation is leaving keeps
88
+ // its invalidated value instead of refetching: this render is disposed at
89
+ // commit and never paints, and refiring would put a second request on
90
+ // what single flight promises is one. The entry stays a cache miss, so
91
+ // the fetch happens at its next real use — a later navigation's preload
92
+ // or read — unless the flight payload seeds it first. Retained sections
93
+ // still refetch here, inside the navigation's transition, so the
94
+ // redirect commits with fresh data.
95
+ const leaving = cached && !cached[0] && route && router.leaving?.(route);
85
96
  if (cached &&
86
- cached[0] &&
87
- (isServer ||
88
- intent === "native" ||
89
- cached[4].count ||
90
- Date.now() - cached[0] < PRELOAD_TIMEOUT)) {
97
+ (cached[0]
98
+ ? isServer ||
99
+ intent === "native" ||
100
+ cached[4].count ||
101
+ Date.now() - cached[0] < PRELOAD_TIMEOUT
102
+ : leaving)) {
91
103
  if (tracking) {
92
104
  cached[4].count++;
93
105
  cached[4][0](); // track
94
106
  }
95
- if (cached[3] === "preload" && intent !== "preload") {
107
+ if (!leaving && cached[3] === "preload" && intent !== "preload") {
96
108
  cached[0] = now;
97
109
  }
98
110
  let res = cached[1];
@@ -101,7 +113,7 @@ export function query(fn, name) {
101
113
  "then" in cached[1]
102
114
  ? cached[1].then(handleResponse(false), handleResponse(true))
103
115
  : handleResponse(false)(cached[1]);
104
- !isServer && intent === "navigate" && cached[4][1](cached[0]); // update version
116
+ !leaving && !isServer && intent === "navigate" && cached[4][1](cached[0]); // update version
105
117
  }
106
118
  inPreloadFn && "then" in res && res.catch(() => { });
107
119
  return res;
@@ -171,14 +183,28 @@ export function query(fn, name) {
171
183
  }
172
184
  const url = v.headers.get(LocationHeader);
173
185
  if (url !== null) {
186
+ // invalidate the redirect's revalidation keys before navigating so
187
+ // the destination's preloads see the miss and fetch fresh (#580 thread)
188
+ const keys = !isServer && v.headers.get(REVALIDATE_HEADER)?.split(",");
189
+ keys && cacheKeyOp(keys, entry => (entry[0] = 0));
174
190
  // client + server relative redirect
175
- if (navigate && url.startsWith("/"))
191
+ const soft = navigate && url.startsWith("/");
192
+ if (soft)
176
193
  navigate(url, { replace: true });
177
194
  else if (!isServer)
178
195
  window.location.href = url;
179
196
  else if (e)
180
197
  e.response.status = 302;
181
- return;
198
+ // sweep the live signals inside the same transition as the
199
+ // navigation: surviving consumers (shared layouts) refetch and
200
+ // hold the commit, so the destination never paints stale data
201
+ keys && revalidate(keys, false);
202
+ // Hold the read pending on the client: the navigation unmounts this
203
+ // consumer, and resolving `undefined` instead hands a missing value
204
+ // to whatever renders before the transition commits (a downstream
205
+ // `.map()` crashes). The server keeps resolving — its render can't
206
+ // await a navigation and the 302 discards the body anyway.
207
+ return isServer ? undefined : new Promise(() => { });
182
208
  }
183
209
  if (hasEnveloped)
184
210
  v = enveloped;
package/dist/index.js CHANGED
@@ -1165,6 +1165,12 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1165
1165
  get pendingTarget() {
1166
1166
  return lastTransitionTarget;
1167
1167
  },
1168
+ leaving(route) {
1169
+ const t = lastTransitionTarget;
1170
+ if (!t || !route.retained) return false;
1171
+ const pathname = t.value.split(/[?#]/, 1)[0];
1172
+ return !route.retained(options.transformUrl ? options.transformUrl(pathname) : pathname);
1173
+ },
1168
1174
  renderPath,
1169
1175
  parsePath,
1170
1176
  navigatorFactory,
@@ -1191,7 +1197,6 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1191
1197
  }
1192
1198
  // typed path proxy nodes coerce to their href
1193
1199
  if (typeof to !== "string") to = to.toString();
1194
- const queryOnly = !to || to[0] === "?";
1195
1200
  const {
1196
1201
  replace,
1197
1202
  resolve,
@@ -1199,11 +1204,21 @@ function createRouterContext(integration, branches, getContext, options = {}) {
1199
1204
  state: nextState
1200
1205
  } = {
1201
1206
  replace: false,
1202
- resolve: !queryOnly,
1207
+ resolve: true,
1203
1208
  scroll: true,
1204
1209
  ...options
1205
1210
  };
1206
- const resolvedTo = resolve ? route.resolvePath(to) : resolvePath(queryOnly && location.pathname || "", to);
1211
+
1212
+ // A string means what the same string means as an href. Leading "/"
1213
+ // stays base-prefixed; anything else resolves URL-style against the
1214
+ // current location — `new URL` collapses `..` and handles `?`/`#`-only
1215
+ // references natively (#502). A cross-origin result (scheme or
1216
+ // protocol-relative input) falls through as unroutable.
1217
+ let resolvedTo;
1218
+ if (!resolve) resolvedTo = resolvePath((!to || to[0] === "?") && location.pathname || "", to);else if (to[0] === "/") resolvedTo = route.resolvePath(to);else {
1219
+ const url = new URL(to, mockBase + location.pathname + location.search + location.hash);
1220
+ resolvedTo = url.origin === mockBase ? url.pathname + url.search + url.hash : undefined;
1221
+ }
1207
1222
  if (resolvedTo === undefined) {
1208
1223
  throw new Error(`Path '${to}' is not a routable path`);
1209
1224
  } else if (referrers.length >= MAX_REDIRECTS) {
@@ -1360,6 +1375,13 @@ function createRouteContext(router, parent, outlet, match, matches = () => [matc
1360
1375
  pattern,
1361
1376
  params,
1362
1377
  path,
1378
+ retained(target) {
1379
+ const m = match();
1380
+ const p = m.route.matcher(target);
1381
+ // params derive from the matched path, so equal paths mean this
1382
+ // section instance (layouts match prefixes via partial matchers)
1383
+ return !!p && p.path === m.path;
1384
+ },
1363
1385
  outlet: () => component ? createComponent(component, {
1364
1386
  params,
1365
1387
  location,
@@ -2174,7 +2196,9 @@ function query(fn, name) {
2174
2196
  const intent = getIntent();
2175
2197
  const inPreloadFn = getInPreloadFn();
2176
2198
  const owner = getOwner();
2177
- const navigate = owner ? useNavigate() : undefined;
2199
+ const router = owner ? useRouter() : undefined;
2200
+ const route = owner ? useRoute() : undefined;
2201
+ const navigate = router && router.navigatorFactory();
2178
2202
  const now = Date.now();
2179
2203
  const key = name + hashKey(args);
2180
2204
  let cached = cache.get(key);
@@ -2197,18 +2221,28 @@ function query(fn, name) {
2197
2221
  tracking = true;
2198
2222
  onCleanup(() => cached[4].count--);
2199
2223
  }
2200
- if (cached && cached[0] && (isServer || intent === "native" || cached[4].count || Date.now() - cached[0] < PRELOAD_TIMEOUT)) {
2224
+
2225
+ // A read under a route section the in-flight navigation is leaving keeps
2226
+ // its invalidated value instead of refetching: this render is disposed at
2227
+ // commit and never paints, and refiring would put a second request on
2228
+ // what single flight promises is one. The entry stays a cache miss, so
2229
+ // the fetch happens at its next real use — a later navigation's preload
2230
+ // or read — unless the flight payload seeds it first. Retained sections
2231
+ // still refetch here, inside the navigation's transition, so the
2232
+ // redirect commits with fresh data.
2233
+ const leaving = cached && !cached[0] && route && router.leaving?.(route);
2234
+ if (cached && (cached[0] ? isServer || intent === "native" || cached[4].count || Date.now() - cached[0] < PRELOAD_TIMEOUT : leaving)) {
2201
2235
  if (tracking) {
2202
2236
  cached[4].count++;
2203
2237
  cached[4][0](); // track
2204
2238
  }
2205
- if (cached[3] === "preload" && intent !== "preload") {
2239
+ if (!leaving && cached[3] === "preload" && intent !== "preload") {
2206
2240
  cached[0] = now;
2207
2241
  }
2208
2242
  let res = cached[1];
2209
2243
  if (intent !== "preload") {
2210
2244
  res = "then" in cached[1] ? cached[1].then(handleResponse(false), handleResponse(true)) : handleResponse(false)(cached[1]);
2211
- !isServer && intent === "navigate" && cached[4][1](cached[0]); // update version
2245
+ !leaving && !isServer && intent === "navigate" && cached[4][1](cached[0]); // update version
2212
2246
  }
2213
2247
  inPreloadFn && "then" in res && res.catch(() => {});
2214
2248
  return res;
@@ -2267,11 +2301,28 @@ function query(fn, name) {
2267
2301
  }
2268
2302
  const url = v.headers.get(LocationHeader);
2269
2303
  if (url !== null) {
2304
+ // invalidate the redirect's revalidation keys before navigating so
2305
+ // the destination's preloads see the miss and fetch fresh (#580 thread)
2306
+ const keys = !isServer && v.headers.get(REVALIDATE_HEADER)?.split(",");
2307
+ keys && cacheKeyOp(keys, entry => entry[0] = 0);
2308
+
2270
2309
  // client + server relative redirect
2271
- if (navigate && url.startsWith("/")) navigate(url, {
2310
+ const soft = navigate && url.startsWith("/");
2311
+ if (soft) navigate(url, {
2272
2312
  replace: true
2273
2313
  });else if (!isServer) window.location.href = url;else if (e) e.response.status = 302;
2274
- return;
2314
+
2315
+ // sweep the live signals inside the same transition as the
2316
+ // navigation: surviving consumers (shared layouts) refetch and
2317
+ // hold the commit, so the destination never paints stale data
2318
+ keys && revalidate(keys, false);
2319
+
2320
+ // Hold the read pending on the client: the navigation unmounts this
2321
+ // consumer, and resolving `undefined` instead hands a missing value
2322
+ // to whatever renders before the transition commits (a downstream
2323
+ // `.map()` crashes). The server keeps resolving — its render can't
2324
+ // await a navigation and the 302 discards the body anyway.
2325
+ return isServer ? undefined : new Promise(() => {});
2275
2326
  }
2276
2327
  if (hasEnveloped) v = enveloped;else if (v.body) {
2277
2328
  // responses the transport hands over whole (revalidation) carry a
@@ -2660,7 +2711,11 @@ function applyResponseMetadata(metadata, navigate, flightData) {
2660
2711
  cacheKeyOp(keys, entry => entry[0] = 0);
2661
2712
  // set cache
2662
2713
  flightData && Object.keys(flightData).forEach(k => query.set(k, flightData[k]));
2663
- // trigger revalidation
2714
+ // trigger revalidation inside the same transition as the navigation, so
2715
+ // the redirect commits atomically with fresh data: surviving consumers
2716
+ // (shared layouts) the flight payload didn't seed refetch and hold the
2717
+ // commit rather than painting stale and updating after. Seeded entries
2718
+ // are fresh again by now, so the sweep re-reads them from cache.
2664
2719
  revalidate(keys, false);
2665
2720
  }
2666
2721
  async function handleResponse(response, error, navigate, metadataHandled) {
package/dist/routing.js CHANGED
@@ -621,6 +621,13 @@ export function createRouterContext(integration, branches, getContext, options =
621
621
  get pendingTarget() {
622
622
  return lastTransitionTarget;
623
623
  },
624
+ leaving(route) {
625
+ const t = lastTransitionTarget;
626
+ if (!t || !route.retained)
627
+ return false;
628
+ const pathname = t.value.split(/[?#]/, 1)[0];
629
+ return !route.retained(options.transformUrl ? options.transformUrl(pathname) : pathname);
630
+ },
624
631
  renderPath,
625
632
  parsePath,
626
633
  navigatorFactory,
@@ -650,16 +657,27 @@ export function createRouterContext(integration, branches, getContext, options =
650
657
  // typed path proxy nodes coerce to their href
651
658
  if (typeof to !== "string")
652
659
  to = to.toString();
653
- const queryOnly = !to || to[0] === "?";
654
660
  const { replace, resolve, scroll, state: nextState } = {
655
661
  replace: false,
656
- resolve: !queryOnly,
662
+ resolve: true,
657
663
  scroll: true,
658
664
  ...options
659
665
  };
660
- const resolvedTo = resolve
661
- ? route.resolvePath(to)
662
- : resolvePath((queryOnly && location.pathname) || "", to);
666
+ // A string means what the same string means as an href. Leading "/"
667
+ // stays base-prefixed; anything else resolves URL-style against the
668
+ // current location — `new URL` collapses `..` and handles `?`/`#`-only
669
+ // references natively (#502). A cross-origin result (scheme or
670
+ // protocol-relative input) falls through as unroutable.
671
+ let resolvedTo;
672
+ if (!resolve)
673
+ resolvedTo = resolvePath(((!to || to[0] === "?") && location.pathname) || "", to);
674
+ else if (to[0] === "/")
675
+ resolvedTo = route.resolvePath(to);
676
+ else {
677
+ const url = new URL(to, mockBase + location.pathname + location.search + location.hash);
678
+ resolvedTo =
679
+ url.origin === mockBase ? url.pathname + url.search + url.hash : undefined;
680
+ }
663
681
  if (resolvedTo === undefined) {
664
682
  throw new Error(`Path '${to}' is not a routable path`);
665
683
  }
@@ -797,6 +815,13 @@ export function createRouteContext(router, parent, outlet, match, matches = () =
797
815
  pattern,
798
816
  params,
799
817
  path,
818
+ retained(target) {
819
+ const m = match();
820
+ const p = m.route.matcher(target);
821
+ // params derive from the matched path, so equal paths mean this
822
+ // section instance (layouts match prefixes via partial matchers)
823
+ return !!p && p.path === m.path;
824
+ },
800
825
  outlet: () => component
801
826
  ? createComponent(component, {
802
827
  params,
package/dist/types.d.ts CHANGED
@@ -74,6 +74,12 @@ export interface Location<S = unknown> extends Path {
74
74
  key: string;
75
75
  }
76
76
  export interface NavigateOptions<S = unknown> {
77
+ /**
78
+ * Resolve the target like an href: leading-`/` paths are prefixed with the
79
+ * router base, anything else resolves URL-style against the current
80
+ * location (`..` collapses, `?`/`#`-only strings keep the path). `false`
81
+ * takes the string as the final path. Defaults to `true`.
82
+ */
77
83
  resolve: boolean;
78
84
  replace: boolean;
79
85
  scroll: boolean;
@@ -238,6 +244,8 @@ export interface RouteContext {
238
244
  path: () => string;
239
245
  outlet: () => JSX.Element;
240
246
  resolvePath(to: string): string | undefined;
247
+ /** Whether this route section still matches `target` (same matched path), i.e. survives a navigation there. Absent on the base route, which always survives. */
248
+ retained?(target: string): boolean;
241
249
  }
242
250
  export interface RouterUtils {
243
251
  renderPath(path: string): string;
@@ -256,6 +264,8 @@ export interface RouterContext {
256
264
  isRouting: () => boolean;
257
265
  /** The target of the in-flight navigation transition, if any. Not reactive. */
258
266
  readonly pendingTarget?: LocationChange;
267
+ /** Whether `route` is a section the in-flight navigation is leaving. False when no navigation is pending. */
268
+ leaving?(route: RouteContext): boolean;
259
269
  matches: () => RouteMatch[];
260
270
  renderPath(path: string): string;
261
271
  parsePath(str: string): string;
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "Ryan Turnquist"
7
7
  ],
8
8
  "license": "MIT",
9
- "version": "2.0.0-next.12",
9
+ "version": "2.0.0-next.13",
10
10
  "homepage": "https://github.com/solidjs/solid-router#readme",
11
11
  "repository": {
12
12
  "type": "git",