bosia 0.8.5 → 0.8.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
@@ -63,6 +63,26 @@
63
63
  let firstNav = true;
64
64
  let navDoneTimer: ReturnType<typeof setTimeout> | null = null;
65
65
 
66
+ // Scroll after a nav settles: forward navs go to top (or the URL hash);
67
+ // back/forward navs restore the position saved for that history entry.
68
+ // Runs after tick() so the destination page's real height is in the DOM.
69
+ // goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
70
+ function settleScroll() {
71
+ if (router.isPush && !router.suppressScroll) {
72
+ const hash = window.location.hash;
73
+ tick().then(() => {
74
+ if (!scrollToHash(hash)) window.scrollTo(0, 0);
75
+ });
76
+ } else if (!router.isPush) {
77
+ const pos = router.pendingScroll;
78
+ // ponytail: single post-tick restore; content that loads later (images
79
+ // without dimensions) can still shift it. Revisit only if reported.
80
+ if (pos) tick().then(() => window.scrollTo(pos.x, pos.y));
81
+ }
82
+ router.pendingScroll = null;
83
+ router.suppressScroll = false;
84
+ }
85
+
66
86
  $effect(() => {
67
87
  if (ssrMode) return;
68
88
 
@@ -247,13 +267,7 @@
247
267
  appState.errorComponent = errMod.default;
248
268
  appState.errorProps = { error: { status: errStatus, message: errMessage } };
249
269
  appState.errorDepth = K;
250
- if (router.isPush && !router.suppressScroll) {
251
- const hash = window.location.hash;
252
- tick().then(() => {
253
- if (!scrollToHash(hash)) window.scrollTo(0, 0);
254
- });
255
- }
256
- router.suppressScroll = false;
270
+ settleScroll();
257
271
  settle({ url, params: match.params });
258
272
  } catch {
259
273
  window.location.href = path;
@@ -342,16 +356,7 @@
342
356
  appState.errorProps = null;
343
357
  appState.errorDepth = null;
344
358
 
345
- // Scroll to top on forward navigation (not on popstate/back-forward).
346
- // goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
347
- // If the destination URL has a hash, scroll to that element instead.
348
- if (router.isPush && !router.suppressScroll) {
349
- const hash = window.location.hash;
350
- tick().then(() => {
351
- if (!scrollToHash(hash)) window.scrollTo(0, 0);
352
- });
353
- }
354
- router.suppressScroll = false;
359
+ settleScroll();
355
360
 
356
361
  // Update document title and meta description from server metadata
357
362
  if (result?.metadata) {
@@ -16,6 +16,20 @@ function buildTarget(path: string): { url: URL; params: Record<string, string> }
16
16
  return { url, params: match?.params ?? {} };
17
17
  }
18
18
 
19
+ // ─── Scroll restoration ───
20
+ // Positions keyed by a per-entry index stamped into `history.state.bosiaIndex`.
21
+ // `scrollRestoration = "manual"` because the browser restores at popstate time,
22
+ // before the SPA has rendered the destination page — it clamps against the
23
+ // wrong document height. We restore ourselves after the nav settles (App.svelte).
24
+ // Persisted to sessionStorage on unload so reload / back-into-app still restores.
25
+ const SCROLL_KEY = "bosia:scroll";
26
+ let scrollPositions: Record<number, { x: number; y: number }> = {};
27
+ let historyIndex = 0;
28
+
29
+ function saveScroll() {
30
+ scrollPositions[historyIndex] = { x: window.scrollX, y: window.scrollY };
31
+ }
32
+
19
33
  export function scrollToHash(hash: string): boolean {
20
34
  if (typeof document === "undefined" || !hash) return false;
21
35
  const raw = hash.startsWith("#") ? hash.slice(1) : hash;
@@ -45,6 +59,8 @@ export const router = new (class Router {
45
59
  lastNavType: NavType = "enter";
46
60
  /** Set by `goto({ noScroll: true })`; consumed once by App.svelte after the next nav settles. */
47
61
  suppressScroll = false;
62
+ /** Saved scroll position for the entry a popstate landed on; consumed once by App.svelte after the nav settles. */
63
+ pendingScroll: { x: number; y: number } | null = null;
48
64
 
49
65
  navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}) {
50
66
  if (this.currentRoute === path) return;
@@ -74,12 +90,15 @@ export const router = new (class Router {
74
90
 
75
91
  this.lastNavType = navType;
76
92
  this.isPush = true;
93
+ this.pendingScroll = null;
77
94
  this.currentRoute = finalPath;
78
95
  if (typeof history !== "undefined") {
79
96
  if (opts.replace) {
80
- history.replaceState({}, "", finalPath);
97
+ history.replaceState({ bosiaIndex: historyIndex }, "", finalPath);
81
98
  } else {
82
- history.pushState({}, "", finalPath);
99
+ saveScroll();
100
+ historyIndex++;
101
+ history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
83
102
  }
84
103
  }
85
104
  }
@@ -87,6 +106,22 @@ export const router = new (class Router {
87
106
  init() {
88
107
  if (typeof window === "undefined") return;
89
108
 
109
+ history.scrollRestoration = "manual";
110
+ try {
111
+ scrollPositions = JSON.parse(sessionStorage.getItem(SCROLL_KEY) ?? "{}");
112
+ } catch {
113
+ scrollPositions = {};
114
+ }
115
+ const stamped = history.state?.bosiaIndex != null;
116
+ historyIndex = stamped ? history.state.bosiaIndex : 0;
117
+ history.replaceState({ ...history.state, bosiaIndex: historyIndex }, "");
118
+ // Restore after a reload — manual mode means the browser won't. Only for
119
+ // entries we stamped before (a fresh visit has no bosiaIndex in state).
120
+ const initial = scrollPositions[historyIndex];
121
+ if (stamped && initial) {
122
+ requestAnimationFrame(() => window.scrollTo(initial.x, initial.y));
123
+ }
124
+
90
125
  // Intercept <a> clicks for client-side navigation
91
126
  window.addEventListener("click", (e) => {
92
127
  // Let browser handle non-primary buttons, modifier-clicks, already-handled events
@@ -110,7 +145,9 @@ export const router = new (class Router {
110
145
  e.preventDefault();
111
146
  const finalPath = anchor.pathname + anchor.search + anchor.hash;
112
147
  if (this.currentRoute !== finalPath) {
113
- history.pushState({}, "", finalPath);
148
+ saveScroll();
149
+ historyIndex++;
150
+ history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
114
151
  this.currentRoute = finalPath;
115
152
  }
116
153
  scrollToHash(anchor.hash);
@@ -122,8 +159,13 @@ export const router = new (class Router {
122
159
  });
123
160
 
124
161
  // Browser back/forward
125
- window.addEventListener("popstate", () => {
162
+ window.addEventListener("popstate", (e) => {
126
163
  const finalPath = window.location.pathname + window.location.search + window.location.hash;
164
+ // Save the position of the entry we're leaving (historyIndex is still
165
+ // the old entry's), then look up where the landed-on entry was.
166
+ saveScroll();
167
+ historyIndex = e.state?.bosiaIndex ?? 0;
168
+ this.pendingScroll = scrollPositions[historyIndex] ?? null;
127
169
  // Fire beforeNavigate listeners; popstate can't be reliably cancelled
128
170
  // (browser history already advanced), so we surface the event for
129
171
  // observation only — `cancel()` is a no-op for this source.
@@ -147,6 +189,14 @@ export const router = new (class Router {
147
189
  // listeners can warn-on-leave (return value ignored; cancellation here
148
190
  // requires `beforeunload`, not in scope).
149
191
  window.addEventListener("beforeunload", () => {
192
+ // ponytail: beforeunload can be skipped on mobile page-discard; add a
193
+ // visibilitychange persist if restore-after-reload proves flaky there.
194
+ saveScroll();
195
+ try {
196
+ sessionStorage.setItem(SCROLL_KEY, JSON.stringify(scrollPositions));
197
+ } catch {
198
+ // sessionStorage unavailable (private mode / quota) — skip persistence.
199
+ }
150
200
  const fromTarget = buildTarget(this.currentRoute);
151
201
  const nav: Navigation = {
152
202
  from: fromTarget,
@@ -1,12 +1,10 @@
1
1
  import { parse, compile } from "svelte/compiler";
2
2
  import MagicString from "magic-string";
3
- import { basename, relative } from "node:path";
3
+ import { relative } from "node:path";
4
4
  import type { BunPlugin } from "bun";
5
5
  import { svelteMapCache } from "../../svelteCompiler.ts";
6
6
  import { lineColFromOffset } from "../../sourceLoc.ts";
7
7
 
8
- const VIRTUAL_NS = "bosia-inspector-css";
9
-
10
8
  type AnyNode = {
11
9
  type?: string;
12
10
  name?: string;
@@ -124,7 +122,6 @@ const fnv = (s: string): string => {
124
122
  export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPlugin {
125
123
  const { cwd, target, dev } = opts;
126
124
  const generate: "client" | "server" = target === "browser" ? "client" : "server";
127
- const virtualCss = new Map<string, string>();
128
125
 
129
126
  return {
130
127
  name: "bosia-inspector",
@@ -139,7 +136,11 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
139
136
  generate,
140
137
  dev,
141
138
  hmr: dev,
142
- css: "external",
139
+ // Mirror the prod compiler (svelteCompiler.ts): client injects scoped
140
+ // CSS into the JS via `append_styles`, server discards it. No CSS
141
+ // chunks means Bun's `splitting:true` output-path collisions can't
142
+ // arise, so no runtime-injection workaround is needed.
143
+ css: generate === "client" ? "injected" : "external",
143
144
  preserveWhitespace: dev,
144
145
  preserveComments: dev,
145
146
  cssHash: ({ css }) => `svelte-${fnv(css)}`,
@@ -164,39 +165,9 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
164
165
  svelteMapCache.set(args.path, m);
165
166
  }
166
167
 
167
- let js = dev ? fixBindShadow(result.js.code) : result.js.code;
168
- // Inject component <style> blocks via runtime JS, not CSS chunks.
169
- // Bun's `splitting: true` names CSS chunks after the importing JS
170
- // chunk's `[name]`, not the virtual module's uid — so when several
171
- // routes (e.g. multiple `+page.svelte`) transitively import the same
172
- // styled component, each route emits its own CSS sidecar named
173
- // `+page-<contentHash>.css`. Identical content → identical hash →
174
- // "Multiple files share the same output path". Runtime injection
175
- // avoids CSS chunking entirely.
176
- if (result.css?.code?.trim() && generate !== "server") {
177
- const safeBase = basename(args.path).replace(/\./g, "_");
178
- const uid = `${safeBase}-${fnv(args.path)}-style.css`;
179
- const virtualName = `${VIRTUAL_NS}:${uid}`;
180
- virtualCss.set(virtualName, result.css.code);
181
- js += `\nimport ${JSON.stringify(virtualName)};`;
182
- }
183
-
168
+ const js = dev ? fixBindShadow(result.js.code) : result.js.code;
184
169
  return { contents: js, loader: "ts" };
185
170
  });
186
-
187
- build.onResolve({ filter: new RegExp(`^${VIRTUAL_NS}:`) }, (args) => ({
188
- path: args.path,
189
- namespace: VIRTUAL_NS,
190
- }));
191
-
192
- build.onLoad({ filter: /.*/, namespace: VIRTUAL_NS }, (args) => {
193
- const css = virtualCss.get(args.path) ?? "";
194
- virtualCss.delete(args.path);
195
- const contents = css
196
- ? `(()=>{const s=document.createElement('style');s.textContent=${JSON.stringify(css)};document.head.appendChild(s);})();`
197
- : "";
198
- return { contents, loader: "js" };
199
- });
200
171
  },
201
172
  };
202
173
  }