bosia 1.2.1 → 1.2.3

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.
@@ -6,7 +6,7 @@
6
6
  // disabled because nothing is wired up until this action runs.
7
7
 
8
8
  import { appState } from "./appState.svelte.ts";
9
- import { router } from "./router.svelte.ts";
9
+ import { go } from "./navigation.ts";
10
10
 
11
11
  export type ActionResult =
12
12
  | { type: "success"; status: number; data: any }
@@ -37,7 +37,11 @@ async function applyResult(
37
37
  const invalidateAll = opts.invalidateAll !== false;
38
38
 
39
39
  if (result.type === "redirect") {
40
- router.navigate(result.location);
40
+ // The action changed data — maybe the session (login/logout) — so reload every
41
+ // loader, also on a redirect to this same page (post/redirect/get). Its result
42
+ // is spent: an earlier failure's message must not outlive it.
43
+ appState.form = null;
44
+ await go(result.location, { source: "form" }, invalidateAll);
41
45
  return;
42
46
  }
43
47
  if (result.type === "error") {
@@ -47,6 +47,9 @@ async function main() {
47
47
  }
48
48
  }
49
49
  router.currentRoute = window.location.pathname + window.location.search + window.location.hash;
50
+ // After canonicalizing, before any await: a click during the chunk loads below
51
+ // moves currentRoute, and App must still know which route the server rendered.
52
+ router.hydratedRoute = router.currentRoute;
50
53
  initPrefetch();
51
54
 
52
55
  let ssrPageComponent = null;
@@ -79,8 +82,10 @@ async function main() {
79
82
  // next client navigation can decide which loaders to skip without an
80
83
  // extra fetch round-trip.
81
84
  if (match) {
82
- const url = new URL(window.location.href);
83
- const ctx = liveContext(window.location.pathname, match.params, url);
85
+ // The server's route, not window.location: a click during the chunk loads
86
+ // above already moved the address bar on.
87
+ const url = new URL(router.hydratedRoute, window.location.origin);
88
+ const ctx = liveContext(url.pathname, match.params, url);
84
89
  const ssrPageDeps: LoaderDeps | null = (window as any).__BOSIA_PAGE_DEPS__ ?? null;
85
90
  const ssrLayoutDeps: (LoaderDeps | null)[] = (window as any).__BOSIA_LAYOUT_DEPS__ ?? [];
86
91
  const pageId = (match.route as any).pageId as string | null;
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { onDestroy } from "svelte";
14
14
  import { appState } from "./appState.svelte.ts";
15
- import { router } from "./router.svelte.ts";
15
+ import { router, type NavType } from "./router.svelte.ts";
16
16
  import {
17
17
  afterListeners,
18
18
  beforeListeners,
@@ -106,7 +106,7 @@ export function invalidateAll(): Promise<void> {
106
106
  export interface GotoOptions {
107
107
  /** Use `history.replaceState` instead of `history.pushState`. */
108
108
  replaceState?: boolean;
109
- /** Mark every loader dirty so the next nav re-runs all of them. */
109
+ /** Re-run every loader for the destination — also when it is the current URL. */
110
110
  invalidateAll?: boolean;
111
111
  /** Skip the default scroll-to-top after navigation. */
112
112
  noScroll?: boolean;
@@ -118,41 +118,60 @@ export interface GotoOptions {
118
118
 
119
119
  /**
120
120
  * Navigate to `url` via the client router. Returns a Promise that resolves
121
- * after the navigation has settled (loaders ran, components mounted) or
122
- * immediately if the URL matches the current route.
121
+ * after the navigation has settled (loaders ran, components mounted). On the
122
+ * current URL it resolves immediately — unless `invalidateAll` is set, then
123
+ * after the loaders re-ran in place.
123
124
  */
124
125
  export function goto(url: string, opts: GotoOptions = {}): Promise<void> {
125
- if (typeof window === "undefined") return Promise.resolve();
126
-
127
- if (opts.invalidateAll) {
128
- appState.dirty.all = true;
129
- }
130
- if (opts.noScroll) {
131
- router.suppressScroll = true;
132
- }
126
+ return go(
127
+ url,
128
+ { replace: opts.replaceState, noScroll: opts.noScroll, source: "goto" },
129
+ !!opts.invalidateAll,
130
+ );
131
+ }
133
132
 
134
- const beforePath = router.currentRoute;
133
+ /** Internal — `goto()` and `use:enhance` redirects. `reload` re-runs every loader (enhance: unless `update({ invalidateAll: false })`). */
134
+ export function go(
135
+ url: string,
136
+ opts: { replace?: boolean; noScroll?: boolean; source?: NavType },
137
+ reload: boolean,
138
+ ): Promise<void> {
139
+ if (typeof window === "undefined") return Promise.resolve();
135
140
 
136
141
  return new Promise<void>((resolve) => {
137
- appState.navResolvers.push(resolve);
138
- router.navigate(url, { replace: opts.replaceState, source: "goto" });
139
- // `navigate()` short-circuits when `currentRoute === path` — the nav
140
- // effect won't fire, so nothing will drain the resolver. Resolve now.
141
- if (router.currentRoute === beforePath) {
142
- drainNavResolvers();
143
- }
142
+ const result = router.navigate(url, { replace: opts.replace, source: opts.source });
143
+ // The nav effect runs after this, so marks set now still reach it — and a nav
144
+ // that never starts (cancelled, left the app) leaves none behind for the next one.
145
+ if (result === "started" && opts.noScroll) router.suppressScroll = true;
146
+ const refetch = reload && (result === "started" || result === "same");
147
+ // The tick wakes the effect on the same URL, and on a new one it keeps a
148
+ // prefetch holding pre-mutation data from standing in for the refetch.
149
+ if (refetch) invalidateAll();
150
+ // "same" while a nav to it is still loading waits for it too.
151
+ const wait = result === "started" || (result === "same" && (reload || router.loading));
152
+ // Otherwise nothing will settle: resolve just this call — draining the queue
153
+ // would also release a nav still loading.
154
+ if (wait) appState.navResolvers.push(resolve);
155
+ else resolve();
144
156
  });
145
157
  }
146
158
 
147
159
  /** Internal — App.svelte calls this after each nav effect settles. */
148
- export function drainNavResolvers(): void {
160
+ // Takes the waiters now (a nav started meanwhile queues its own), releases them
161
+ // once `after` (the settling nav's scroll) is done.
162
+ export function drainNavResolvers(after?: Promise<unknown>): void {
149
163
  const queue = appState.navResolvers;
150
164
  appState.navResolvers = [];
151
- for (const fn of queue) {
152
- try {
153
- fn();
154
- } catch (err) {
155
- console.warn("[bosia] navResolver threw", err);
165
+ const run = () => {
166
+ for (const fn of queue) {
167
+ try {
168
+ fn();
169
+ } catch (err) {
170
+ console.warn("[bosia] navResolver threw", err);
171
+ }
156
172
  }
157
- }
173
+ };
174
+ // A throwing flush rejects the scroll; the waiters must not hang on it.
175
+ if (after) after.then(run, run);
176
+ else run();
158
177
  }
@@ -14,6 +14,9 @@ import { withBase } from "../basePath.ts";
14
14
  // pushes exactly the URL that was in the link, untouched.
15
15
 
16
16
  export type NavType = "link" | "goto" | "popstate" | "form" | "enter";
17
+ /** What `router.navigate()` did: started a client nav, found the URL already current,
18
+ * was cancelled by `beforeNavigate`, or handed off to a full page load. */
19
+ export type NavResult = "started" | "same" | "cancelled" | "left";
17
20
 
18
21
  function buildTarget(path: string): { url: URL; params: Record<string, string> } | null {
19
22
  if (typeof window === "undefined") return null;
@@ -94,8 +97,13 @@ export const router = new (class Router {
94
97
  /** Captured snapshot for the entry a popstate/reload landed on; consumed once by App.svelte. `undefined` = nothing to restore. */
95
98
  pendingSnapshot: unknown = undefined;
96
99
 
97
- navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}) {
98
- if (this.currentRoute === path) return;
100
+ /** The route the server rendered (canonical), recorded by hydrate.ts before App mounts. */
101
+ hydratedRoute = "";
102
+ /** True while App's nav effect is loading a run; `goto()` to the current URL waits on it. */
103
+ loading = false;
104
+
105
+ navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}): NavResult {
106
+ if (this.currentRoute === path) return "same";
99
107
  // Unknown route — let the server handle it (renders +error.svelte with 404)
100
108
  const queryHash = path.slice(path.split("?")[0].split("#")[0].length);
101
109
  const pathname = path.split("?")[0].split("#")[0];
@@ -114,12 +122,16 @@ export const router = new (class Router {
114
122
  `[bosia] "${path}" matched no route and is leaving the app — it is mounted at "${base}". Use "${withBase(base, path)}".`,
115
123
  );
116
124
  }
117
- window.location.href = path;
118
- return;
125
+ // A replace (loader redirect) must not leave a Back-button trap behind.
126
+ if (opts.replace) window.location.replace(path);
127
+ else window.location.href = path;
128
+ return "left";
119
129
  }
120
130
  // Canonicalize trailing slash before navigating (matches server 308 behavior)
121
131
  const canonical = canonicalPathname(pathname, (match.route as any).trailingSlash ?? "never");
122
132
  const finalPath = canonical !== null ? canonical + queryHash : path;
133
+ // `/about/` on `/about`: nothing to do, and no duplicate history entry.
134
+ if (this.currentRoute === finalPath) return "same";
123
135
 
124
136
  const navType: NavType = opts.source ?? "link";
125
137
  const fromTarget = buildTarget(this.currentRoute);
@@ -131,11 +143,12 @@ export const router = new (class Router {
131
143
  willUnload: false,
132
144
  cancel: () => {},
133
145
  };
134
- if (!fireBeforeNavigate(nav)) return;
146
+ if (!fireBeforeNavigate(nav)) return "cancelled";
135
147
 
136
148
  this.lastNavType = navType;
137
149
  this.isPush = true;
138
150
  this.pendingScroll = null;
151
+ this.suppressScroll = false; // a superseded noScroll goto's flag; go() re-sets its own after
139
152
  this.pendingSnapshot = undefined;
140
153
  this.currentRoute = finalPath;
141
154
  if (typeof history !== "undefined") {
@@ -148,6 +161,10 @@ export const router = new (class Router {
148
161
  history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
149
162
  }
150
163
  }
164
+ // Set now, not when App's effect runs a microtask later: a same-tick goto() to
165
+ // this URL must already see it loading.
166
+ this.loading = true;
167
+ return "started";
151
168
  }
152
169
 
153
170
  init() {
@@ -202,6 +219,7 @@ export const router = new (class Router {
202
219
  savePageSnapshot();
203
220
  historyIndex++;
204
221
  history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
222
+ this.suppressScroll = false;
205
223
  this.currentRoute = finalPath;
206
224
  }
207
225
  scrollToHash(anchor.hash);
@@ -238,6 +256,7 @@ export const router = new (class Router {
238
256
 
239
257
  this.lastNavType = "popstate";
240
258
  this.isPush = false;
259
+ this.suppressScroll = false;
241
260
  this.currentRoute = finalPath;
242
261
  });
243
262
 
@@ -5,6 +5,12 @@
5
5
  /** The title a page gets when nothing else sets one. */
6
6
  export const DEFAULT_TITLE = "Bosia App";
7
7
 
8
+ /** The title an error page gets when its components set none: `404 — Not Found`.
9
+ * SSR and client navigation both use it, so the two never drift apart. */
10
+ export function errorTitle(status: number, message: string): string {
11
+ return `${status} — ${message}`;
12
+ }
13
+
8
14
  /** Whether a chunk of head markup already sets the document title. Matches a
9
15
  * <title> with attributes too. Ignores one in a comment, an attribute value, a
10
16
  * script or JSON-LD string, <style>, <noscript>, <template>, or an inline <svg>
@@ -4,10 +4,8 @@ import type { RouteMatch, TrailingSlash } from "./types.ts";
4
4
  // Single shared matcher used by both client and server at runtime.
5
5
  // Replaces the old code-generated match functions.
6
6
  //
7
- // Matching priority (same as old / SvelteKit):
8
- // 1. Exact match — "/about" === "/about"
9
- // 2. Dynamic match — "/blog/[slug]" matches "/blog/hello"
10
- // 3. Catch-all match — "/[...rest]" matches anything
7
+ // Routes arrive sorted by sortRoutes (routeFile.ts): segment by segment,
8
+ // static beats "[param]" beats "[...rest]" (same as SvelteKit). First match wins.
11
9
 
12
10
  // ─── Compiled Route Types ────────────────────────────────
13
11
 
@@ -19,6 +17,28 @@ interface CompiledRoute {
19
17
 
20
18
  // ─── Pattern Compiler ────────────────────────────────────
21
19
 
20
+ // Brackets wrap the whole segment; the name has no brackets or slash and doesn't
21
+ // start with a dot (so `[...]` isn't a param named "..."). Anything else —
22
+ // `[id].json`, `[[lang]]` — is static here, and the scanner fails the build on it.
23
+ const SEGMENT = /^\[(\.\.\.)?([^[\]/.][^[\]/]*)\]$/;
24
+
25
+ /** How every part of Bosia reads one route segment (matcher, sortRoutes, scanner):
26
+ * 0 static, 1 `[param]`, 2 `[...rest]`. Doubles as the sort rank. */
27
+ export function segmentRank(seg: string): 0 | 1 | 2 {
28
+ const m = SEGMENT.exec(seg);
29
+ return !m ? 0 : m[1] ? 2 : 1;
30
+ }
31
+
32
+ /** The param name a dynamic segment binds: `[slug]` → `slug`, `[...rest]` → `rest`. */
33
+ export function segmentName(seg: string): string {
34
+ return SEGMENT.exec(seg)?.[2] ?? "";
35
+ }
36
+
37
+ /** True when any segment of the pattern is a param or catch-all. */
38
+ export function isDynamic(pattern: string): boolean {
39
+ return pattern.split("/").some((seg) => segmentRank(seg) > 0);
40
+ }
41
+
22
42
  /** Escape regex special chars in a literal string segment. */
23
43
  function escapeRegex(s: string): string {
24
44
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -29,30 +49,25 @@ function escapeRegex(s: string): string {
29
49
  */
30
50
  function compilePattern(pattern: string): CompiledRoute {
31
51
  // No dynamic segments — exact match via ===
32
- if (!pattern.includes("[")) {
52
+ if (!isDynamic(pattern)) {
33
53
  return { regex: null!, paramNames: [], isExact: true };
34
54
  }
35
55
 
36
56
  const paramNames: string[] = [];
37
57
 
38
- // Catch-all: /prefix/[...name]
39
- const catchallMatch = pattern.match(/^(.*?)\/\[\.\.\.(\w+)\]$/);
40
- if (catchallMatch) {
41
- const prefix = catchallMatch[1] || "";
42
- paramNames.push(catchallMatch[2]!);
43
- const escaped = prefix ? escapeRegex(prefix) : "";
44
- // Root catch-all /[...rest] must have at least one char after /
45
- const regex = prefix ? new RegExp(`^${escaped}\\/(.+)$`) : new RegExp(`^\\/(.+)$`);
46
- return { regex, paramNames, isExact: false };
47
- }
48
-
49
- // Dynamic segments: /blog/[slug]/comments → ^\/blog\/([^/]+)\/comments$
58
+ // /blog/[slug]/comments → ^\/blog\/([^/]+)\/comments$
59
+ // A trailing [...rest] takes the rest of the path: /[lang]/[...rest]. Only the
60
+ // last segment gets (.+), so a hand-built table can't nest them (ReDoS); the
61
+ // scanner fails the build on a non-last catch-all anyway.
50
62
  const segments = pattern.split("/").filter(Boolean);
51
63
  let regexStr = "^";
52
- for (const seg of segments) {
64
+ for (const [i, seg] of segments.entries()) {
53
65
  regexStr += "\\/";
54
- if (seg.startsWith("[") && seg.endsWith("]")) {
55
- paramNames.push(seg.slice(1, -1));
66
+ const rank = segmentRank(seg);
67
+ if (rank) paramNames.push(segmentName(seg));
68
+ if (rank === 2 && i === segments.length - 1) {
69
+ regexStr += "(.+)";
70
+ } else if (rank) {
56
71
  regexStr += "([^/]+)";
57
72
  } else {
58
73
  regexStr += escapeRegex(seg);
@@ -66,7 +81,7 @@ function compilePattern(pattern: string): CompiledRoute {
66
81
  // Exact routes bucketed by pathname, keyed by the routes-array identity so
67
82
  // findMatch can O(1) Map.get before linearly scanning the dynamic/catch-all
68
83
  // remainder. WeakMap keeps it tied to whichever array (server/api/client) was
69
- // compiled; uncompiled arrays simply fall back to the full linear scan.
84
+ // compiled; findMatch compiles an array it hasn't seen on first lookup.
70
85
  const exactMaps = new WeakMap<object, Map<string, unknown>>();
71
86
 
72
87
  /**
@@ -82,62 +97,13 @@ export function compileRoutes<T extends { pattern: string }>(
82
97
  const compiled = compilePattern(route.pattern);
83
98
  (route as any)._compiled = compiled;
84
99
  // Exact patterns contain no dynamic segments, so pattern === pathname.
85
- if (compiled.isExact) exact.set(route.pattern, route);
100
+ // First wins, like the sorted scan.
101
+ if (compiled.isExact && !exact.has(route.pattern)) exact.set(route.pattern, route);
86
102
  }
87
103
  exactMaps.set(routes, exact);
88
104
  return routes as (T & { _compiled: CompiledRoute })[];
89
105
  }
90
106
 
91
- // ─── Legacy Pattern Matcher (fallback for uncompiled routes) ─
92
-
93
- /**
94
- * Match a URL pathname against a single route pattern.
95
- * Returns extracted params if matched, null otherwise.
96
- */
97
- function matchPattern(pattern: string, pathname: string): Record<string, string> | null {
98
- // Strip trailing slash (but keep "/" as-is)
99
- if (pathname.length > 1 && pathname.endsWith("/")) {
100
- pathname = pathname.slice(0, -1);
101
- }
102
-
103
- // Exact match
104
- if (pattern === pathname) return {};
105
-
106
- // Catch-all pattern: /[...name] or /prefix/[...name]
107
- const catchallMatch = pattern.match(/^(.*?)\/\[\.\.\.(\w+)\]$/);
108
- if (catchallMatch) {
109
- const prefix = catchallMatch[1] || "";
110
- const paramName = catchallMatch[2]!;
111
- if (prefix === "" || pathname.startsWith(prefix + "/") || pathname === prefix) {
112
- const rest = prefix ? pathname.slice(prefix.length + 1) : pathname.slice(1);
113
- // Don't let a root catch-all match "/" with an empty slug.
114
- // If you want the catch-all to also serve "/", add an explicit +page.svelte at the root.
115
- if (!prefix && rest === "") return null;
116
- return { [paramName]: rest };
117
- }
118
- return null;
119
- }
120
-
121
- // Dynamic segments: must have same segment count
122
- if (!pattern.includes("[")) return null;
123
-
124
- const patParts = pattern.split("/").filter(Boolean);
125
- const pathParts = pathname.split("/").filter(Boolean);
126
- if (patParts.length !== pathParts.length) return null;
127
-
128
- const params: Record<string, string> = {};
129
- for (let i = 0; i < patParts.length; i++) {
130
- const pp = patParts[i]!;
131
- const val = pathParts[i]!;
132
- if (pp.startsWith("[") && pp.endsWith("]")) {
133
- params[pp.slice(1, -1)] = val;
134
- } else if (pp !== val) {
135
- return null;
136
- }
137
- }
138
- return params;
139
- }
140
-
141
107
  // ─── Route Matching ──────────────────────────────────────
142
108
 
143
109
  /**
@@ -165,32 +131,33 @@ function matchCompiled(
165
131
 
166
132
  /**
167
133
  * Find the first matching route from a list.
168
- * Routes must be pre-sorted by priority (exact → dynamic → catch-all).
134
+ * Routes must be pre-sorted by sortRoutes (most specific segment first).
169
135
  * Single pass — first match wins.
170
136
  */
171
137
  export function findMatch<T extends { pattern: string }>(
172
138
  routes: T[],
173
139
  pathname: string,
174
140
  ): RouteMatch<T> | null {
175
- // Strip trailing slash (but keep "/" as-is)
141
+ // Strip one trailing slash (but keep "/" as-is), same as canonicalPathname.
176
142
  if (pathname.length > 1 && pathname.endsWith("/")) {
177
143
  pathname = pathname.slice(0, -1);
178
144
  }
179
145
 
180
- // Exact-match fast path: one Map.get instead of scanning every exact route.
181
- const exact = exactMaps.get(routes) as Map<string, T> | undefined;
182
- if (exact) {
183
- const hit = exact.get(pathname);
184
- if (hit) return { route: hit, params: {} };
146
+ // Arrays that skipped compileRoutes get compiled on first lookup.
147
+ let exact = exactMaps.get(routes) as Map<string, T> | undefined;
148
+ if (!exact) {
149
+ compileRoutes(routes);
150
+ exact = exactMaps.get(routes) as Map<string, T>;
185
151
  }
186
152
 
153
+ // Exact-match fast path: one Map.get instead of scanning every exact route.
154
+ const hit = exact.get(pathname);
155
+ if (hit) return { route: hit, params: {} };
156
+
187
157
  for (const route of routes) {
188
- const compiled = (route as any)._compiled as CompiledRoute | undefined;
189
- // Exact routes already covered by the Map lookup above — skip them.
190
- if (exact && compiled?.isExact) continue;
191
- const params = compiled
192
- ? matchCompiled(compiled, route.pattern, pathname)
193
- : matchPattern(route.pattern, pathname);
158
+ // A route pushed after compileRoutes compiles here, once.
159
+ const compiled: CompiledRoute = ((route as any)._compiled ??= compilePattern(route.pattern));
160
+ const params = matchCompiled(compiled, route.pattern, pathname);
194
161
  if (params !== null) return { route, params };
195
162
  }
196
163