@solidjs/router 1.0.0 → 2.0.0-next.12

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.
Files changed (56) hide show
  1. package/README.md +606 -697
  2. package/dist/claims.d.ts +21 -0
  3. package/dist/claims.js +115 -0
  4. package/dist/data/action.d.ts +40 -11
  5. package/dist/data/action.js +303 -101
  6. package/dist/data/events.d.ts +8 -0
  7. package/dist/data/events.js +24 -23
  8. package/dist/data/index.d.ts +2 -4
  9. package/dist/data/index.js +2 -4
  10. package/dist/data/query.d.ts +1 -3
  11. package/dist/data/query.js +39 -22
  12. package/dist/data/serverForms.d.ts +1 -0
  13. package/dist/data/serverForms.js +5 -0
  14. package/dist/fs.d.ts +94 -0
  15. package/dist/fs.js +51 -0
  16. package/dist/index.d.ts +24 -3
  17. package/dist/index.js +1890 -1200
  18. package/dist/index.jsx +2 -2
  19. package/dist/lifecycle.d.ts +29 -4
  20. package/dist/lifecycle.js +40 -37
  21. package/dist/paths.d.ts +117 -0
  22. package/dist/paths.js +41 -0
  23. package/dist/routers/components.d.ts +12 -26
  24. package/dist/routers/components.jsx +68 -54
  25. package/dist/routers/factory.d.ts +121 -0
  26. package/dist/routers/factory.jsx +154 -0
  27. package/dist/routers/history.d.ts +26 -0
  28. package/dist/routers/history.js +180 -0
  29. package/dist/routers/index.d.ts +5 -11
  30. package/dist/routers/index.js +2 -6
  31. package/dist/routers/scrollRestoration.d.ts +10 -1
  32. package/dist/routers/scrollRestoration.js +49 -11
  33. package/dist/routing.d.ts +96 -52
  34. package/dist/routing.js +399 -167
  35. package/dist/server.d.ts +47 -0
  36. package/dist/server.js +156 -0
  37. package/dist/types.d.ts +148 -44
  38. package/dist/utils.d.ts +2 -0
  39. package/dist/utils.js +2 -0
  40. package/package.json +12 -7
  41. package/dist/components.d.ts +0 -31
  42. package/dist/components.jsx +0 -40
  43. package/dist/data/createAsync.d.ts +0 -32
  44. package/dist/data/createAsync.js +0 -96
  45. package/dist/data/response.d.ts +0 -4
  46. package/dist/data/response.js +0 -42
  47. package/dist/routers/HashRouter.d.ts +0 -9
  48. package/dist/routers/HashRouter.js +0 -41
  49. package/dist/routers/MemoryRouter.d.ts +0 -24
  50. package/dist/routers/MemoryRouter.js +0 -57
  51. package/dist/routers/Router.d.ts +0 -17
  52. package/dist/routers/Router.js +0 -59
  53. package/dist/routers/StaticRouter.d.ts +0 -6
  54. package/dist/routers/StaticRouter.js +0 -15
  55. package/dist/routers/createRouter.d.ts +0 -10
  56. package/dist/routers/createRouter.js +0 -41
package/dist/routing.js CHANGED
@@ -1,15 +1,23 @@
1
- import { runWithOwner, batch } from "solid-js";
2
- import { createComponent, createContext, createMemo, createRenderEffect, createSignal, on, onCleanup, untrack, useContext, startTransition, resetErrorBoundaries } from "solid-js";
3
- import { isServer, getRequestEvent } from "solid-js/web";
4
- import { createBeforeLeave } from "./lifecycle.js";
5
- import { mockBase, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals } from "./utils.js";
1
+ import { flush, runWithOwner } from "solid-js";
2
+ import { createComponent, createContext, createMemo, createSignal, isPending, NotReadyError, untrack, useContext } from "solid-js";
3
+ import { isServer, getRequestEvent } from "@solidjs/web";
4
+ import { mockBase, comparablePath, createMemoObject, extractSearchParams, invariant, resolvePath, createMatcher, joinPaths, scoreRoute, mergeSearchString, expandOptionals } from "./utils.js";
5
+ import { clearFlashCookie, hasFlashCookie } from "@solidjs/web/server-functions";
6
6
  const MAX_REDIRECTS = 100;
7
7
  /** Consider this API opaque and internal. It is likely to change in the future. */
8
8
  export const RouterContextObj = createContext();
9
9
  export const RouteContextObj = createContext();
10
+ export function useOptionalContext(context) {
11
+ try {
12
+ return useContext(context);
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
10
18
  export const useRouter = () => invariant(useContext(RouterContextObj), "<A> and 'use' router primitives can be only used inside a Route.");
11
19
  let TempRoute;
12
- export const useRoute = () => TempRoute || useContext(RouteContextObj) || useRouter().base;
20
+ export const useRoute = () => TempRoute || useOptionalContext(RouteContextObj) || useRouter().base;
13
21
  export const useResolvedPath = (path) => {
14
22
  const route = useRoute();
15
23
  return createMemo(() => route.resolvePath(path()));
@@ -53,8 +61,8 @@ export const useNavigate = () => useRouter().navigatorFactory();
53
61
  */
54
62
  export const useLocation = () => useRouter().location;
55
63
  /**
56
- * Retrieves signal that indicates whether the route is currently in a *Transition*.
57
- * Useful for showing stale/pending state when the route resolution is *Suspended* during concurrent rendering.
64
+ * Retrieves a signal that indicates whether the router is currently processing a navigation.
65
+ * Useful for showing pending navigation state while the next route and its data settle.
58
66
  *
59
67
  * @example
60
68
  * ```js
@@ -68,35 +76,30 @@ export const useLocation = () => useRouter().location;
68
76
  * ```
69
77
  */
70
78
  export const useIsRouting = () => useRouter().isRouting;
71
- /**
72
- * usePreloadRoute returns a function that can be used to preload a route manual.
73
- * This is what happens automatically with link hovering and similar focus based behavior, but it is available here as an API.
74
- *
75
- * @example
76
- * ```js
77
- * const preload = usePreloadRoute();
78
- *
79
- * preload(`/users/settings`, { preloadData: true });
80
- * ```
81
- */
82
- export const usePreloadRoute = () => {
83
- const pre = useRouter().preloadRoute;
84
- return (url, options = {}) => pre(url instanceof URL ? url : new URL(url, mockBase), options.preloadData);
85
- };
86
79
  /**
87
80
  * `useMatch` takes an accessor that returns the path and creates a `Memo` that returns match information if the current path matches the provided path.
88
81
  * Useful for determining if a given path matches the current route.
89
82
  *
83
+ * Accepts a pattern string — the match's `params` are typed from it — or a
84
+ * typed path node (a concrete URL, so no params to speak of):
85
+ *
90
86
  * @example
91
87
  * ```js
92
88
  * const match = useMatch(() => props.href);
93
89
  *
94
90
  * return <div classList={{ active: Boolean(match()) }} />;
91
+ *
92
+ * const section = useMatch(() => "/docs/:page");
93
+ * section()?.params.page; // string
94
+ *
95
+ * const here = useMatch(() => paths.users(2));
95
96
  * ```
96
97
  */
97
98
  export const useMatch = (path, matchFilters) => {
98
99
  const location = useLocation();
99
- const matchers = createMemo(() => expandOptionals(path()).map(path => createMatcher(path, undefined, matchFilters)));
100
+ const matchers = createMemo(() =>
101
+ // `String()` folds typed path nodes to their URL
102
+ expandOptionals(String(path())).map(path => createMatcher(path, undefined, matchFilters)));
100
103
  return createMemo(() => {
101
104
  for (const matcher of matchers()) {
102
105
  const match = matcher(location.pathname);
@@ -106,61 +109,44 @@ export const useMatch = (path, matchFilters) => {
106
109
  });
107
110
  };
108
111
  /**
109
- * `useCurrentMatches` returns all the matches for the current matched route.
110
- * Useful for getting all the route information.
112
+ * `useRouteMatches` returns an accessor of the router's resolved matches for
113
+ * the current location — the chain of route definitions producing the current
114
+ * render, outermost first. Useful for reading `info` metadata off the matched
115
+ * chain. Unlike `useMatch`, which tests a path pattern you supply against the
116
+ * location, this reflects the route tree itself.
111
117
  *
112
118
  * @example
113
119
  * ```js
114
- * const matches = useCurrentMatches();
120
+ * const matches = useRouteMatches();
115
121
  *
116
- * const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb))
122
+ * const breadcrumbs = createMemo(() => matches().map(m => m.route.info.breadcrumb));
117
123
  * ```
118
124
  */
119
- export const useCurrentMatches = () => {
125
+ export const useRouteMatches = () => {
120
126
  const router = useRouter();
121
127
  // return a copy so user mutations (eg. `.reverse()`) can't corrupt router state
122
128
  return () => router.matches().slice();
123
129
  };
124
130
  /**
125
- * Retrieves a reactive, store-like object containing the current route path parameters as defined in the Route.
131
+ * `usePreloadRoute` returns a function for warming a route by hand — the same
132
+ * work link hover/focus intent triggers automatically: the matched routes'
133
+ * lazy components load, and with `preloadData` their `preload` functions run.
126
134
  *
127
135
  * @example
128
136
  * ```js
129
- * const params = useParams();
130
- *
131
- * // fetch user based on the id path parameter
132
- * const [user] = createResource(() => params.id, fetchUser);
133
- * ```
134
- */
135
- export const useParams = () => useRouter().params;
136
- /**
137
- * Retrieves a tuple containing a reactive object to read the current location's query parameters and a method to update them.
138
- * The object is a proxy so you must access properties to subscribe to reactive updates.
139
- * **Note** that values will be strings and property names will retain their casing.
140
- *
141
- * The setter method accepts an object whose entries will be merged into the current query string.
142
- * Values `''`, `undefined` and `null` will remove the key from the resulting query string.
143
- * Updates will behave just like a navigation and the setter accepts the same optional second parameter as `navigate` and auto-scrolling is disabled by default.
144
- *
145
- * @examples
146
- * ```js
147
- * const [searchParams, setSearchParams] = useSearchParams();
137
+ * const preload = usePreloadRoute();
148
138
  *
149
- * return (
150
- * <div>
151
- * <span>Page: {searchParams.page}</span>
152
- * <button
153
- * onClick={() =>
154
- * setSearchParams({ page: (parseInt(searchParams.page) || 0) + 1 })
155
- * }
156
- * >
157
- * Next Page
158
- * </button>
159
- * </div>
160
- * );
139
+ * preload(paths.users(2).settings, { preloadData: true });
161
140
  * ```
162
141
  */
163
- export const useSearchParams = () => {
142
+ export const usePreloadRoute = () => {
143
+ const pre = useRouter().preloadRoute;
144
+ return (url, options = {}) => pre(url instanceof URL ? url : new URL(String(url), mockBase), options.preloadData);
145
+ };
146
+ export function useParams(_path) {
147
+ return useRoute().params;
148
+ }
149
+ export function useSearchParams(path) {
164
150
  const router = useRouter();
165
151
  const location = router.location;
166
152
  const navigate = useNavigate();
@@ -180,55 +166,172 @@ export const useSearchParams = () => {
180
166
  ...options
181
167
  });
182
168
  };
183
- return [location.query, setSearchParams];
184
- };
169
+ // Passing a paths node opts into schema parsing. The node itself is a
170
+ // type-level reference; the schemas that run come from the currently
171
+ // matched routes (root→leaf), whose outputs merge over the raw query.
172
+ // A schema that reports issues is skipped, leaving raw values — search
173
+ // strings are user input, so defaults belong in the schema itself.
174
+ const query = path
175
+ ? createMemoObject(createMemo(() => {
176
+ const raw = { ...location.query };
177
+ let result;
178
+ for (const match of router.matches()) {
179
+ const schema = match.route.key.search;
180
+ if (!schema)
181
+ continue;
182
+ const outcome = schema["~standard"].validate(raw);
183
+ if (outcome instanceof Promise)
184
+ throw new Error("Async Standard Schema validation is not supported for search params");
185
+ if (!outcome.issues)
186
+ result = Object.assign(result || { ...raw }, outcome.value);
187
+ }
188
+ return result || raw;
189
+ }))
190
+ : location.query;
191
+ return [query, setSearchParams];
192
+ }
185
193
  /**
186
- * useBeforeLeave takes a function that will be called prior to leaving a route.
187
- * The function will be called with:
188
- *
189
- * - from (*Location*): current location (before change).
190
- * - to (*string | number*): path passed to `navigate`.
191
- * - options (*NavigateOptions*): options passed to navigate.
192
- * - preventDefault (*function*): call to block the route change.
193
- * - defaultPrevented (*readonly boolean*): `true` if any previously called leave handlers called `preventDefault`.
194
- * - retry (*function*, force?: boolean ): call to retry the same navigation, perhaps after confirming with the user. Pass `true` to skip running the leave handlers again (i.e. force navigate without confirming).
194
+ * Reactive link state for custom link components — the programmatic
195
+ * counterpart of the attribute vocabulary plain anchors receive
196
+ * (`aria-current`, `data-active`, `data-pending`).
195
197
  *
196
198
  * @example
197
- * ```js
198
- * useBeforeLeave((e: BeforeLeaveEventArgs) => {
199
- * if (form.isDirty && !e.defaultPrevented) {
200
- * // preventDefault to block immediately and prompt user async
201
- * e.preventDefault();
202
- * setTimeout(() => {
203
- * if (window.confirm("Discard unsaved changes - are you sure?")) {
204
- * // user wants to proceed anyway so retry with force=true
205
- * e.retry(true);
206
- * }
207
- * }, 100);
208
- * }
209
- * });
199
+ * ```tsx
200
+ * function TabLink(props: { href: string; children: JSX.Element }) {
201
+ * const link = useLinkState(() => props.href);
202
+ * return (
203
+ * <a href={props.href} class="tab" data-selected={link.active() || undefined}>
204
+ * {props.children}
205
+ * </a>
206
+ * );
207
+ * }
210
208
  * ```
211
209
  */
212
- export const useBeforeLeave = (listener) => {
213
- const s = useRouter().beforeLeave.subscribe({
214
- listener,
215
- location: useLocation(),
216
- navigate: useNavigate()
210
+ export const useLinkState = (href, options = {}) => {
211
+ const router = useRouter();
212
+ const location = router.location;
213
+ const to = useResolvedPath(() => String(href()));
214
+ // trailing slashes are ignored so `/route` and `/route/` share state
215
+ const path = createMemo(() => {
216
+ const to_ = to();
217
+ return to_ === undefined ? undefined : comparablePath(to_);
217
218
  });
218
- onCleanup(s);
219
+ const matches = (loc) => {
220
+ const path_ = path();
221
+ if (path_ === undefined)
222
+ return [false, false];
223
+ const exact = loc === path_;
224
+ return [exact || (!options.end && loc.startsWith(path_ + "/")), exact];
225
+ };
226
+ const state = createMemo(() => matches(decodeURI(comparablePath(location.pathname))));
227
+ return {
228
+ active: () => state()[0],
229
+ current: () => state()[1],
230
+ // match the in-flight target explicitly (rather than active-while-routing)
231
+ // so the answer is the same from pure reads and from effects, which
232
+ // observe the committed location during a transition
233
+ pending: createMemo(() => {
234
+ state(); // location dependency: mid-flight target swaps recompute
235
+ return (router.isRouting() &&
236
+ !!router.pendingTarget &&
237
+ matches(decodeURI(comparablePath(router.pendingTarget.value)))[0]);
238
+ })
239
+ };
219
240
  };
220
241
  // Encodes a static path segment like `encodeURIComponent`, but leaves RFC 3986
221
242
  // pchar characters (sub-delims / ":" / "@") literal, matching how browsers
222
243
  // report them in `location.pathname`. Non-ASCII characters (eg. CJK paths) are
223
244
  // still percent-encoded exactly as before, since browsers encode those too.
224
245
  const encodeSegment = (s) => encodeURIComponent(s).replace(/%(2B|40|3A|24|26|2C|3B|3D)/g, m => decodeURIComponent(m));
246
+ // ---------------------------------------------------------------------------
247
+ // Lazy route subtrees
248
+ // ---------------------------------------------------------------------------
249
+ //
250
+ // A `children` thunk (`() => import("./feature/routes")`) is a *boundary*:
251
+ // until it resolves, the compiled tree carries a param-less catch-all
252
+ // placeholder branch under the boundary's pattern (splat-scored, so static
253
+ // siblings still win). Resolution is append-only and cached per thunk, then a
254
+ // module-level version signal bumps and every `branches()` consumer
255
+ // recompiles — matches, params, and route states all react. The placeholder's
256
+ // component reads a memo of the resolution promise, which keeps the enclosing
257
+ // navigation transition pending exactly like a `lazy()` route component; its
258
+ // `preload` *is* the resolver, so hover-intent preloading kicks the table
259
+ // load through the existing component-preload path.
260
+ const lazyBoundaries = new WeakMap();
261
+ // Module scope: boundary resolution is global, deterministic state (same
262
+ // thunk -> same routes), shared by every factory instance and the server's
263
+ // flight collector.
264
+ const [lazyTreeVersion, setLazyTreeVersion] = createSignal(0);
265
+ /** Reactive read of the lazy-subtree version — recompile compiled branches when it changes. */
266
+ export function trackLazySubtrees() {
267
+ return lazyTreeVersion();
268
+ }
269
+ /** Non-reactive read, for cache-busting outside the reactive graph (server collectors). */
270
+ export function peekLazySubtrees() {
271
+ return untrack(lazyTreeVersion);
272
+ }
273
+ function getLazyBoundary(thunk) {
274
+ let record = lazyBoundaries.get(thunk);
275
+ if (!record)
276
+ lazyBoundaries.set(thunk, (record = { thunk }));
277
+ return record;
278
+ }
279
+ /**
280
+ * Kicks (or joins) a boundary's resolution. Returns the resolved routes
281
+ * synchronously once available, the in-flight promise otherwise. Commit is
282
+ * always async — even for thunks returning arrays — so the version bump
283
+ * never writes a signal from inside a render computation.
284
+ */
285
+ export function resolveLazySubtree(record) {
286
+ if (record.resolved)
287
+ return record.resolved;
288
+ return (record.promise ||= Promise.resolve(record.thunk()).then(m => {
289
+ const routes = Array.isArray(m)
290
+ ? m
291
+ : m
292
+ .default ||
293
+ m.routes ||
294
+ [];
295
+ record.resolved = routes;
296
+ setLazyTreeVersion(v => v + 1);
297
+ return record.resolved;
298
+ }));
299
+ }
300
+ /**
301
+ * The unresolved boundaries in a match chain. Rendering gates on these (the
302
+ * route-states memo suspends until they land — see routers/components.tsx)
303
+ * and the server's flight collector awaits them before its preload pass.
304
+ */
305
+ export function unresolvedLazyMatches(matches) {
306
+ const pending = [];
307
+ for (const match of matches)
308
+ if (match.route.lazy && !match.route.lazy.resolved)
309
+ pending.push(match.route.lazy);
310
+ return pending;
311
+ }
312
+ function createLazyPlaceholder(pattern, record) {
313
+ // The placeholder never renders and needs no component — `matches` parks
314
+ // on unresolved boundaries before route contexts are created (kicking the
315
+ // resolver as it does), preloadRoute kicks it directly, and the version
316
+ // bump swaps in the real routes. `path + "/*"` with no splat name matches
317
+ // the boundary itself and everything beneath it without recording a param
318
+ // (createMatcher skips empty splat names).
319
+ const placeholderPattern = pattern + "/*";
320
+ return {
321
+ key: record,
322
+ originalPath: "*",
323
+ pattern: placeholderPattern,
324
+ matcher: createMatcher(placeholderPattern),
325
+ lazy: record
326
+ };
327
+ }
225
328
  export function createRoutes(routeDef, base = "") {
226
- const { component, preload, load, children, info } = routeDef;
329
+ const { component, preload, children, info } = routeDef;
227
330
  const isLeaf = !children || (Array.isArray(children) && !children.length);
228
331
  const shared = {
229
332
  key: routeDef,
230
333
  component,
231
- preload: preload || load,
334
+ preload,
232
335
  info
233
336
  };
234
337
  return asArray(routeDef.path).reduce((acc, originalPath) => {
@@ -285,9 +388,24 @@ export function createBranches(routeDef, base = "", stack = [], branches = []) {
285
388
  const routes = createRoutes(def, base);
286
389
  for (const route of routes) {
287
390
  stack.push(route);
288
- const isEmptyArray = Array.isArray(def.children) && def.children.length === 0;
289
- if (def.children && !isEmptyArray) {
290
- createBranches(def.children, route.pattern, stack, branches);
391
+ let children = def.children;
392
+ if (typeof children === "function") {
393
+ const record = getLazyBoundary(children);
394
+ if (record.resolved) {
395
+ children = record.resolved;
396
+ }
397
+ else {
398
+ // unresolved boundary: a catch-all placeholder holds its ground
399
+ stack.push(createLazyPlaceholder(route.pattern, record));
400
+ branches.push(createBranch([...stack], branches.length));
401
+ stack.pop();
402
+ stack.pop();
403
+ continue;
404
+ }
405
+ }
406
+ const isEmptyArray = Array.isArray(children) && children.length === 0;
407
+ if (children && !isEmptyArray) {
408
+ createBranches(children, route.pattern, stack, branches);
291
409
  }
292
410
  else {
293
411
  const branch = createBranch([...stack], branches.length);
@@ -309,9 +427,16 @@ export function getRouteMatches(branches, location) {
309
427
  }
310
428
  return [];
311
429
  }
430
+ export function mergeParams(matches) {
431
+ const params = {};
432
+ for (let i = 0; i < matches.length; i++) {
433
+ Object.assign(params, matches[i].params);
434
+ }
435
+ return params;
436
+ }
312
437
  function createLocation(path, state, queryWrapper) {
313
438
  const origin = new URL(mockBase);
314
- const url = createMemo(prev => {
439
+ const url = createMemo((prev = origin) => {
315
440
  const path_ = path();
316
441
  try {
317
442
  // anchor rooted paths against the origin explicitly - a path with
@@ -322,14 +447,14 @@ function createLocation(path, state, queryWrapper) {
322
447
  console.error(`Invalid path ${path_}`);
323
448
  return prev;
324
449
  }
325
- }, origin, {
450
+ }, {
326
451
  equals: (a, b) => a.href === b.href
327
452
  });
328
453
  const pathname = createMemo(() => url().pathname);
329
- const search = createMemo(() => url().search, true);
454
+ const search = createMemo(() => url().search);
330
455
  const hash = createMemo(() => url().hash);
331
456
  const key = () => "";
332
- const queryFn = on(search, () => extractSearchParams(url()));
457
+ const queryFn = createMemo(() => extractSearchParams(url()));
333
458
  return {
334
459
  get pathname() {
335
460
  return pathname();
@@ -349,6 +474,49 @@ function createLocation(path, state, queryWrapper) {
349
474
  query: queryWrapper ? queryWrapper(queryFn) : createMemoObject(queryFn)
350
475
  };
351
476
  }
477
+ /**
478
+ * Rendezvous between the router and the data layer's single-flight consumer.
479
+ * The Router registers itself at mount (unless `singleFlight={false}`); the
480
+ * action side provides the consumer factory when the first action is created
481
+ * (see data/action.ts). Whichever side arrives first waits for the other, so
482
+ * an action module loaded lazily (a code-split route) still attaches to the
483
+ * already-mounted router — and a router-only app, where no action ever
484
+ * loads, never subscribes to the transport, so the server is never asked to
485
+ * collect.
486
+ */
487
+ let flightConsumerFactory;
488
+ const flightRouters = new Map();
489
+ export function registerFlightRouter(router) {
490
+ flightRouters.set(router, flightConsumerFactory && flightConsumerFactory(router));
491
+ return () => {
492
+ const unsubscribe = flightRouters.get(router);
493
+ flightRouters.delete(router);
494
+ unsubscribe && unsubscribe();
495
+ };
496
+ }
497
+ export function provideFlightConsumer(factory) {
498
+ if (flightConsumerFactory)
499
+ return;
500
+ flightConsumerFactory = factory;
501
+ for (const [router, unsubscribe] of flightRouters) {
502
+ if (!unsubscribe)
503
+ flightRouters.set(router, factory(router));
504
+ }
505
+ }
506
+ /**
507
+ * The flash-cookie codec, provided by the action side (data/action.ts) so
508
+ * the router core never carries it: the core consumes the cookie eagerly
509
+ * per request (detection + one-shot clear via the runtime's isomorphic half)
510
+ * but defers decoding to this slot, read when the submissions signal
511
+ * initializes. Actions are created at module scope, so on the server the
512
+ * decoder is always installed before useSubmission can read — and a
513
+ * router-only app, where it never installs, has no actions that could have
514
+ * produced a flash cookie in the first place.
515
+ */
516
+ let flashDecoder;
517
+ export function provideFlashDecoder(decoder) {
518
+ flashDecoder || (flashDecoder = decoder);
519
+ }
352
520
  let intent;
353
521
  export function getIntent() {
354
522
  return intent;
@@ -364,82 +532,91 @@ export function createRouterContext(integration, branches, getContext, options =
364
532
  const { signal: [source, setSource], utils = {} } = integration;
365
533
  const parsePath = utils.parsePath || (p => p);
366
534
  const renderPath = utils.renderPath || (p => p);
367
- const beforeLeave = utils.beforeLeave || createBeforeLeave();
535
+ // An empty slot until `useBeforeLeave` installs the guard on first use.
536
+ const beforeLeave = utils.beforeLeave || {};
368
537
  const basePath = resolvePath("", options.base || "");
538
+ const initialSource = untrack(source);
369
539
  if (basePath === undefined) {
370
540
  throw new Error(`${basePath} is not a valid base path`);
371
541
  }
372
- else if (basePath && !source().value) {
542
+ else if (basePath && !initialSource.value) {
373
543
  setSource({ value: basePath, replace: true, scroll: false });
374
544
  }
375
- const [isRouting, setIsRouting] = createSignal(false);
376
- // Keep track of last target, so that last call to transition wins
545
+ const [isNavigating, setIsRouting] = createSignal(false, { ownedWrite: true });
546
+ // Navigate override written from event handlers.
547
+ const [navigateTarget, setNavigateTarget] = createSignal(undefined, {
548
+ ownedWrite: true
549
+ });
550
+ // Keep track of last target, so that last call to navigate wins
377
551
  let lastTransitionTarget;
378
- // Transition the location to a new value
379
- const transition = (newIntent, newTarget) => {
380
- if (newTarget.value === reference() && newTarget.state === state())
381
- return;
382
- if (lastTransitionTarget === undefined)
383
- setIsRouting(true);
384
- intent = newIntent;
385
- lastTransitionTarget = newTarget;
386
- startTransition(() => {
387
- if (lastTransitionTarget !== newTarget)
388
- return;
389
- setReference(lastTransitionTarget.value);
390
- setState(lastTransitionTarget.state);
391
- resetErrorBoundaries();
392
- if (!isServer)
393
- submissions[1](subs => subs.filter(s => s.pending));
394
- }).finally(() => {
395
- if (lastTransitionTarget !== newTarget)
396
- return;
397
- // Batch, in order for isRouting and final source update to happen together
398
- batch(() => {
399
- intent = undefined;
400
- if (newIntent === "navigate")
401
- navigateEnd(lastTransitionTarget);
402
- setIsRouting(false);
403
- lastTransitionTarget = undefined;
404
- });
405
- });
406
- };
407
- const [reference, setReference] = createSignal(source().value);
408
- const [state, setState] = createSignal(source().state);
409
- const location = createLocation(reference, state, utils.queryWrapper);
552
+ // source() remains canonical for native history changes; navigateTarget()
553
+ // temporarily overrides it for in-flight programmatic navigation.
554
+ const effective = createMemo(() => navigateTarget() ?? source());
555
+ const location = createLocation(() => effective().value, () => effective().state, utils.queryWrapper);
410
556
  const referrers = [];
411
- const submissions = createSignal(isServer ? initFromFlash() : []);
412
- const matches = createMemo(() => {
413
- if (typeof options.transformUrl === "function") {
414
- return getRouteMatches(branches(), options.transformUrl(location.pathname));
557
+ // The flash cookie is consumed eagerly: its one-shot clear (Set-Cookie)
558
+ // must be appended before streaming flushes the response headers, and an
559
+ // unread outcome must not haunt a later request's render. Only detection
560
+ // and clearing happen here (the runtime's isomorphic half); the raw header
561
+ // is stashed and decoding waits for the action-provided codec, read when
562
+ // the lazily allocated submissions signal below first initializes.
563
+ let flashCookieHeader;
564
+ if (isServer) {
565
+ const e = getRequestEvent();
566
+ if (e && !(e.router && e.router.submission)) {
567
+ const cookieHeader = e.request.headers.get("cookie");
568
+ if (hasFlashCookie(cookieHeader)) {
569
+ flashCookieHeader = cookieHeader;
570
+ // one-shot: clear it even when unreadable so it can't haunt later renders
571
+ if (e.response && e.response.headers)
572
+ e.response.headers.append("Set-Cookie", clearFlashCookie());
573
+ }
415
574
  }
416
- return getRouteMatches(branches(), location.pathname);
575
+ }
576
+ let submissions;
577
+ const matches = createMemo(() => {
578
+ const pathname = typeof options.transformUrl === "function"
579
+ ? options.transformUrl(location.pathname)
580
+ : location.pathname;
581
+ const m = getRouteMatches(branches(), pathname);
582
+ // An unresolved lazy subtree parks readers on not-ready semantics — the
583
+ // navigation transition (or the SSR stream) holds until the table lands.
584
+ // NotReadyError (not a returned promise) because a match chain is full
585
+ // of component functions the hydration serializer must never see. The
586
+ // recompute comes from the version-signal dependency on the client and
587
+ // from the carried promise's retry on the server; a boundary nested
588
+ // inside a boundary just parks the recomputed chain again.
589
+ const pending = unresolvedLazyMatches(m);
590
+ if (pending.length)
591
+ throw new NotReadyError(Promise.all(pending.map(resolveLazySubtree)));
592
+ return m;
417
593
  });
418
- const buildParams = () => {
419
- const m = matches();
420
- const params = {};
421
- for (let i = 0; i < m.length; i++) {
422
- Object.assign(params, m[i].params);
423
- }
424
- return params;
425
- };
426
- const params = utils.paramsWrapper
427
- ? utils.paramsWrapper(buildParams, branches)
428
- : createMemoObject(buildParams);
594
+ // Every write is a transition in Solid 2, so a native history pop forks the
595
+ // source signal exactly like programmatic navigation does. isRouting is
596
+ // therefore derived: the manual flag covers navigateFromRoute's explicit
597
+ // window, and isPending over the location/matches read reports any
598
+ // in-flight fork — including popstate traversals and the lazy-subtree
599
+ // resolution matches() parks on.
600
+ const isRouting = createMemo(() => isNavigating() || isPending(() => (matches(), location.search, location.hash)));
601
+ const buildParams = () => mergeParams(matches());
602
+ const wrapParams = utils.paramsWrapper
603
+ ? (getParams) => utils.paramsWrapper(getParams, branches)
604
+ : (getParams) => createMemoObject(getParams);
605
+ const params = wrapParams(buildParams);
429
606
  const baseRoute = {
430
607
  pattern: basePath,
608
+ params,
431
609
  path: () => basePath,
432
610
  outlet: () => null,
433
611
  resolvePath(to) {
434
612
  return resolvePath(basePath, to);
435
613
  }
436
614
  };
437
- // Create a native transition, when source updates
438
- createRenderEffect(on(source, source => transition("native", source), { defer: true }));
439
615
  return {
440
616
  base: baseRoute,
441
617
  location,
442
618
  params,
619
+ wrapParams,
443
620
  isRouting,
444
621
  get pendingTarget() {
445
622
  return lastTransitionTarget;
@@ -451,7 +628,9 @@ export function createRouterContext(integration, branches, getContext, options =
451
628
  beforeLeave,
452
629
  preloadRoute,
453
630
  singleFlight: options.singleFlight === undefined ? true : options.singleFlight,
454
- submissions
631
+ get submissions() {
632
+ return (submissions ||= createSignal(isServer ? initSubmissions() : [], { ownedWrite: true }));
633
+ }
455
634
  };
456
635
  function navigateFromRoute(route, to, options) {
457
636
  // Untrack in case someone navigates in an effect - don't want to track `reference` or route paths
@@ -468,6 +647,9 @@ export function createRouterContext(integration, branches, getContext, options =
468
647
  }
469
648
  return;
470
649
  }
650
+ // typed path proxy nodes coerce to their href
651
+ if (typeof to !== "string")
652
+ to = to.toString();
471
653
  const queryOnly = !to || to[0] === "?";
472
654
  const { replace, resolve, scroll, state: nextState } = {
473
655
  replace: false,
@@ -484,26 +666,47 @@ export function createRouterContext(integration, branches, getContext, options =
484
666
  else if (referrers.length >= MAX_REDIRECTS) {
485
667
  throw new Error("Too many redirects");
486
668
  }
487
- const current = reference();
488
- if (resolvedTo !== current || nextState !== state()) {
669
+ const current = effective();
670
+ if (resolvedTo !== current.value || nextState !== current.state) {
489
671
  if (isServer) {
490
672
  const e = getRequestEvent();
491
673
  e && (e.response = { status: 302, headers: new Headers({ Location: resolvedTo }) });
492
674
  setSource({ value: resolvedTo, replace, scroll, state: nextState });
493
675
  }
494
- else if (beforeLeave.confirm(resolvedTo, options)) {
495
- referrers.push({ value: current, replace, scroll, state: state() });
496
- transition("navigate", {
676
+ else if (!beforeLeave.current || beforeLeave.current.confirm(resolvedTo, options)) {
677
+ referrers.push({ value: current.value, replace, scroll, state: current.state });
678
+ const newTarget = {
497
679
  value: resolvedTo,
498
680
  state: nextState
499
- });
681
+ };
682
+ const firstNavigation = lastTransitionTarget === undefined;
683
+ intent = "navigate";
684
+ // assign the target before flushing so effects that run for the
685
+ // isRouting flip (e.g. pending link state) can read it
686
+ lastTransitionTarget = newTarget;
687
+ if (firstNavigation) {
688
+ setIsRouting(true);
689
+ flush();
690
+ }
691
+ if (lastTransitionTarget === newTarget) {
692
+ setNavigateTarget({ ...lastTransitionTarget });
693
+ queueMicrotask(() => {
694
+ if (lastTransitionTarget !== newTarget)
695
+ return;
696
+ intent = undefined;
697
+ navigateEnd(lastTransitionTarget);
698
+ setNavigateTarget(undefined);
699
+ setIsRouting(false);
700
+ lastTransitionTarget = undefined;
701
+ });
702
+ }
500
703
  }
501
704
  }
502
705
  });
503
706
  }
504
707
  function navigatorFactory(route) {
505
708
  // Workaround for vite issue (https://github.com/vitejs/vite/issues/3803)
506
- route = route || useContext(RouteContextObj) || baseRoute;
709
+ route = route || useOptionalContext(RouteContextObj) || baseRoute;
507
710
  return (to, options) => navigateFromRoute(route, to, options);
508
711
  }
509
712
  function navigateEnd(next) {
@@ -519,6 +722,12 @@ export function createRouterContext(integration, branches, getContext, options =
519
722
  }
520
723
  function preloadRoute(url, preloadData) {
521
724
  const matches = getRouteMatches(branches(), url.pathname);
725
+ // An unresolved lazy subtree in the chain: the placeholder's
726
+ // component.preload (below) kicks the table load; once it lands,
727
+ // preload again so the real inner routes warm too.
728
+ const boundary = matches.find(m => m.route.lazy && !m.route.lazy.resolved);
729
+ boundary &&
730
+ resolveLazySubtree(boundary.route.lazy).then(() => preloadRoute(url, preloadData));
522
731
  const prevIntent = intent;
523
732
  intent = "preload";
524
733
  for (let match in matches) {
@@ -546,15 +755,37 @@ export function createRouterContext(integration, branches, getContext, options =
546
755
  }
547
756
  intent = prevIntent;
548
757
  }
549
- function initFromFlash() {
758
+ // Seeds the initial submission from a no-JS form post: the server
759
+ // function runtime redirected back with the outcome in a one-shot flash
760
+ // cookie (its default no-JS convention), consumed eagerly above and
761
+ // decoded here — so the post-redirect SSR renders useSubmission() state
762
+ // exactly as a scripted submission would. An explicitly pre-seeded
763
+ // `event.router.submission` (framework integrations) takes precedence.
764
+ function initSubmissions() {
550
765
  const e = getRequestEvent();
551
- return (e && e.router && e.router.submission ? [e.router.submission] : []);
766
+ const submission = (e && e.router && e.router.submission) ||
767
+ (flashDecoder && flashCookieHeader !== undefined
768
+ ? flashDecoder(flashCookieHeader)
769
+ : undefined);
770
+ if (!submission)
771
+ return [];
772
+ return [
773
+ {
774
+ ...submission,
775
+ clear() { },
776
+ retry() { }
777
+ }
778
+ ];
552
779
  }
553
780
  }
554
- export function createRouteContext(router, parent, outlet, match) {
555
- const { base, location, params } = router;
781
+ export function createRouteContext(router, parent, outlet, match, matches = () => [match()]) {
782
+ const { base, location, wrapParams } = router;
556
783
  const { pattern, component, preload } = match().route;
557
784
  const path = createMemo(() => match().path);
785
+ // Params scoped to this route's lifetime. `matches` is expected to retain
786
+ // its last valid value while this route is being torn down, so outgoing
787
+ // components and preloads never observe another route's params.
788
+ const params = wrapParams(() => mergeParams(matches()));
558
789
  component &&
559
790
  component.preload &&
560
791
  component.preload();
@@ -564,6 +795,7 @@ export function createRouteContext(router, parent, outlet, match) {
564
795
  const route = {
565
796
  parent,
566
797
  pattern,
798
+ params,
567
799
  path,
568
800
  outlet: () => component
569
801
  ? createComponent(component, {