@fluixi/core 1.0.0-alpha.94 → 1.0.0-alpha.96

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 (63) hide show
  1. package/LICENSE +1 -1
  2. package/dist/cdn/core.global.js +2 -2
  3. package/dist/src/cdn/{chunk-7XM7Q5VR.mjs → chunk-RKBFEFZP.mjs} +125 -10
  4. package/dist/src/cdn/core.cjs +132 -12
  5. package/dist/src/cdn/core.mjs +4 -4
  6. package/dist/src/cdn/{router-VOY43YNM.mjs → router-V2THK27H.mjs} +3 -3
  7. package/dist/src/index.cjs +122 -7
  8. package/dist/src/index.mjs +122 -7
  9. package/dist/src/lib/client/{chunk-TSXM2PNY.mjs → chunk-RNRJHQFG.mjs} +1 -1
  10. package/dist/src/lib/client/index.cjs +133 -13
  11. package/dist/src/lib/client/index.d.ts.map +1 -1
  12. package/dist/src/lib/client/index.js +4 -1
  13. package/dist/src/lib/client/index.mjs +5 -5
  14. package/dist/src/lib/client/{router-BDVPLLYX.mjs → router-F3Y4BIVJ.mjs} +127 -12
  15. package/dist/src/lib/core.cjs +121 -6
  16. package/dist/src/lib/core.mjs +121 -6
  17. package/dist/src/lib/i18n/index.cjs +1 -1
  18. package/dist/src/lib/i18n/index.mjs +1 -1
  19. package/dist/src/lib/index.cjs +1 -1
  20. package/dist/src/lib/index.mjs +1 -1
  21. package/dist/src/lib/isomorphic.cjs +1 -1
  22. package/dist/src/lib/isomorphic.d.ts +5 -0
  23. package/dist/src/lib/isomorphic.d.ts.map +1 -1
  24. package/dist/src/lib/isomorphic.js +1 -1
  25. package/dist/src/lib/isomorphic.mjs +1 -1
  26. package/dist/src/lib/jsx-runtime/index.cjs +1 -1
  27. package/dist/src/lib/jsx-runtime/index.mjs +1 -1
  28. package/dist/src/lib/jsx-runtime/jsx-dev-runtime.cjs +1 -1
  29. package/dist/src/lib/jsx-runtime/jsx-dev-runtime.mjs +1 -1
  30. package/dist/src/lib/plugins/index.cjs +1 -1
  31. package/dist/src/lib/plugins/index.mjs +1 -1
  32. package/dist/src/lib/render/index.cjs +1 -1
  33. package/dist/src/lib/render/index.mjs +1 -1
  34. package/dist/src/lib/render/versions.cjs +1 -1
  35. package/dist/src/lib/render/versions.mjs +1 -1
  36. package/dist/src/lib/router/data.cjs +4 -4
  37. package/dist/src/lib/router/data.d.ts.map +1 -1
  38. package/dist/src/lib/router/data.js +9 -8
  39. package/dist/src/lib/router/data.mjs +4 -4
  40. package/dist/src/lib/router/index.cjs +126 -11
  41. package/dist/src/lib/router/index.d.ts +8 -5
  42. package/dist/src/lib/router/index.d.ts.map +1 -1
  43. package/dist/src/lib/router/index.js +94 -7
  44. package/dist/src/lib/router/index.mjs +126 -11
  45. package/dist/src/lib/router/scroll.cjs +79 -0
  46. package/dist/src/lib/router/scroll.d.ts +19 -0
  47. package/dist/src/lib/router/scroll.d.ts.map +1 -0
  48. package/dist/src/lib/router/scroll.js +68 -0
  49. package/dist/src/lib/router/scroll.mjs +58 -0
  50. package/dist/src/lib/server/index.cjs +1 -1
  51. package/dist/src/lib/server/index.mjs +1 -1
  52. package/dist/src/lib/storage/index.cjs +1 -1
  53. package/dist/src/lib/storage/index.mjs +1 -1
  54. package/dist/src/routes.cjs +1 -1
  55. package/dist/src/routes.mjs +1 -1
  56. package/dist/src/utils.cjs +1 -1
  57. package/dist/src/utils.mjs +1 -1
  58. package/dist/src/version.generated.cjs +1 -1
  59. package/dist/src/version.generated.d.ts +1 -1
  60. package/dist/src/version.generated.js +1 -1
  61. package/dist/src/version.generated.mjs +1 -1
  62. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  63. package/package.json +16 -16
@@ -19,6 +19,7 @@ import { insert, createComponent, createNativeElement, setAttribute, } from '@fl
19
19
  import { createContext, useContext } from '@fluixi/reactive/signal';
20
20
  import { get } from '@fluixi/utils/object';
21
21
  import { deepEqual } from '@fluixi/utils/compare';
22
+ import { keepScroll } from './scroll.js';
22
23
  import { createRouter as createCoreRouter, createBrowserHistory, matchRoutes, matchPath, } from '@fluixi/router';
23
24
  const RouterContext = createContext(undefined, 'router');
24
25
  const OutletContext = createContext(undefined, 'outlet');
@@ -55,12 +56,56 @@ function renderLayer(m, depth, base) {
55
56
  });
56
57
  });
57
58
  };
59
+ // Whether the loader looked at the location: only then does a change of query string,
60
+ // which keeps this layer, concern it. Unknown until it has run here: a page hydrated from
61
+ // the server got its data without running it.
62
+ let readsLocation;
58
63
  const [resource, { refetch }] = createResource(() => {
59
- const result = loader({ params: base.params, location: base.location }, owner, () => cancelled, trackDeps);
64
+ // The params and location this load is for, fixed now. Accessors read later would see
65
+ // the next navigation's: a lazy route reads them after its module resolves, and a
66
+ // reload a revalidation started just before a navigation then ran with the next page's
67
+ // params (an empty id).
68
+ const params = untrack(base.params);
69
+ const location = untrack(base.location);
70
+ readsLocation = false;
71
+ const result = loader({
72
+ params: () => params,
73
+ location: () => {
74
+ readsLocation = true;
75
+ return location;
76
+ },
77
+ }, owner, () => cancelled, trackDeps);
60
78
  return router ? router.trackLoad(result) : result;
61
79
  });
62
80
  // A revalidated cache entry: load again. The page stays on screen with its current data.
63
- onCleanup(onRevalidate(() => void refetch()));
81
+ // Not a page on its way out (another route matched at this depth, kept until the next one
82
+ // has its data): its loader would run with the params of the page replacing it.
83
+ onCleanup(onRevalidate(() => {
84
+ if (untrack(() => base.matched()[depth]?.route) !== m.route)
85
+ return;
86
+ void refetch();
87
+ }));
88
+ // A navigation that changes only this layer's params, or only the query string, keeps the
89
+ // layer, so nothing else would load its data for them. Params compared by value: `matched`
90
+ // is rebuilt on every navigation. The query counts only for a loader that read the
91
+ // location; the hash never does.
92
+ let loadedFor = untrack(() => JSON.stringify(base.matched()[depth]?.params ?? {}));
93
+ let searchedFor = untrack(() => base.location().search);
94
+ createEffect(() => {
95
+ const now = base.matched()[depth];
96
+ const search = base.location().search;
97
+ // Another route at this depth: this page is on its way out, kept only until the next
98
+ // one has its data, and the new params are not for it.
99
+ if (now?.route !== m.route)
100
+ return;
101
+ const params = JSON.stringify(now.params ?? {});
102
+ const paramsMoved = params !== loadedFor;
103
+ const searchMoved = readsLocation !== false && search !== searchedFor;
104
+ loadedFor = params;
105
+ searchedFor = search;
106
+ if (paramsMoved || searchMoved)
107
+ untrack(() => void refetch());
108
+ });
64
109
  // A failed load throws where the page reads it, so the nearest ErrorBoundary shows it.
65
110
  // A resource reads as undefined once it has failed, which left the page empty and gave
66
111
  // nothing a chance to catch the error.
@@ -68,6 +113,12 @@ function renderLayer(m, depth, base) {
68
113
  const error = resource.error;
69
114
  if (error !== undefined && error !== null)
70
115
  throw error;
116
+ // Loading again (new params, a revalidation): the page keeps its current data. Reading
117
+ // the resource itself would suspend, and a Suspense boundary above would take the page
118
+ // out of the document until the data arrives, losing what the reader had in it.
119
+ const r = resource;
120
+ if (r.state === 'refreshing')
121
+ return r.latest;
71
122
  return resource();
72
123
  };
73
124
  Object.defineProperty(data, 'loading', { get: () => resource.loading });
@@ -250,6 +301,11 @@ export function Router(props) {
250
301
  * the page would show B.
251
302
  */
252
303
  let navigationId = 0;
304
+ // The window's scroll, for a browser history: where each navigation lands once its page is
305
+ // shown. `arrived` is the navigation whose page was last committed.
306
+ const scroll = history.window ? keepScroll(router.state().location) : undefined;
307
+ let arriving = scroll ? { id: 0, location: router.state().location, from: undefined } : null;
308
+ const [arrived, setArrived] = createSignal(0);
253
309
  /**
254
310
  * How to present the next commit, set by `useNavigate` immediately before it calls
255
311
  * into the core router.
@@ -268,6 +324,10 @@ export function Router(props) {
268
324
  const id = ++navigationId;
269
325
  const mode = takeMode();
270
326
  setNavigating(true);
327
+ if (scroll) {
328
+ scroll.leave();
329
+ arriving = { id, location: next.location, from: untrack(state).location };
330
+ }
271
331
  // Load the modules the next route needs before anything is torn down, so a lazy
272
332
  // route renders straight through instead of suspending into a fallback with the
273
333
  // old page already gone.
@@ -280,6 +340,8 @@ export function Router(props) {
280
340
  const flushed = flush();
281
341
  if (id === navigationId)
282
342
  setNavigating(false);
343
+ if (scroll)
344
+ setArrived(id);
283
345
  return flushed;
284
346
  };
285
347
  if (!prepared) {
@@ -296,6 +358,7 @@ export function Router(props) {
296
358
  });
297
359
  onCleanup(() => {
298
360
  unsub();
361
+ scroll?.dispose();
299
362
  router.destroy();
300
363
  // Anything still preparing is now stale, so nothing can commit into a torn-down
301
364
  // router.
@@ -319,6 +382,27 @@ export function Router(props) {
319
382
  return result;
320
383
  },
321
384
  };
385
+ // Scroll once the page is on screen: after its modules and its data, which is when the
386
+ // router stops routing, and a frame later, before that frame is painted. The page's own
387
+ // effects fill it in after the router's (an article set through innerHTML), and a scroll
388
+ // into a page not yet filled is cut short to its height. A newer navigation takes over
389
+ // from one still loading.
390
+ if (scroll) {
391
+ const nextFrame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 0);
392
+ createEffect(() => {
393
+ const id = arrived();
394
+ if (ctx.isRouting())
395
+ return;
396
+ const next = arriving;
397
+ if (!next || next.id !== id || id !== navigationId)
398
+ return;
399
+ arriving = null;
400
+ nextFrame(() => {
401
+ if (id === navigationId)
402
+ scroll.arrive(next.location, next.from);
403
+ });
404
+ });
405
+ }
322
406
  // Render depth 0 (a virtual parent at depth -1 so the root reuses the same path).
323
407
  const root = {
324
408
  depth: -1,
@@ -650,7 +734,7 @@ export function Link(props) {
650
734
  if (props.slot)
651
735
  setAttribute(a, 'slot', read(props.slot));
652
736
  // Forward any other author attributes (data-*, aria-*, style, id, title, role, ...)
653
- // onto the <a>, the way <Form> does. Without this a component composed via
737
+ // onto the <a>, the way <ActionForm> does. Without this a component composed via
654
738
  // `as={Link}` (polymorphic) silently loses its styling hooks: e.g. @fluixi-ui
655
739
  // parts carry `data-*` slots + inline styles that would otherwise be dropped, so
656
740
  // the element renders unstyled. Own-props handled elsewhere are skipped:
@@ -728,17 +812,20 @@ export function Link(props) {
728
812
  export const A = Link;
729
813
  import { onRevalidate } from './data.js';
730
814
  /**
731
- * A `<form>` wired to an action: on submit it builds `FormData` from the form and calls
815
+ * A `<form>` wired to a router action: on submit it builds `FormData` from the form and calls
732
816
  * `action(formData)` (which tracks submission state via `useSubmission`), without a full
733
817
  * page navigation. The markup carries `method="post"` + `action={action.url}` so it's
734
818
  * progressive-enhancement-ready.
735
819
  *
736
- * <Form action={save}>
820
+ * <ActionForm action={save}>
737
821
  * <input name="title" />
738
822
  * <button>Save</button>
739
- * </Form>
823
+ * </ActionForm>
824
+ *
825
+ * It sends what the fields hold and checks nothing. For a form whose fields are validated
826
+ * first, use `@fluixi-ui/form`'s `Form` with an `action`.
740
827
  */
741
- export function Form(props) {
828
+ export function ActionForm(props) {
742
829
  const action = props.action;
743
830
  const cls = (props.className ?? props.class); // compiler passes class -> className
744
831
  const url = action.url ?? '';
@@ -1,4 +1,4 @@
1
- /*! @fluixi/core v1.0.0-alpha.94 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/core v1.0.0-alpha.96 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
2
 
3
3
  // src/lib/router/index.ts
4
4
  import {
@@ -72,6 +72,64 @@ import {
72
72
  import { createContext as createContext2, useContext as useContext2 } from "@fluixi/reactive/signal";
73
73
  import { get } from "@fluixi/utils/object";
74
74
  import { deepEqual } from "@fluixi/utils/compare";
75
+
76
+ // src/lib/router/scroll.ts
77
+ var STORE = "fx:scroll";
78
+ var KEEP = 100;
79
+ function keepScroll(initial) {
80
+ const read2 = () => {
81
+ try {
82
+ return JSON.parse(sessionStorage.getItem(STORE) ?? "{}");
83
+ } catch {
84
+ return {};
85
+ }
86
+ };
87
+ const positions = read2();
88
+ const write = () => {
89
+ const keys = Object.keys(positions);
90
+ for (const key of keys.slice(0, Math.max(0, keys.length - KEEP))) delete positions[key];
91
+ try {
92
+ sessionStorage.setItem(STORE, JSON.stringify(positions));
93
+ } catch {
94
+ }
95
+ };
96
+ let current = initial.key;
97
+ const save = () => {
98
+ if (current === void 0) return;
99
+ delete positions[current];
100
+ positions[current] = window.scrollY;
101
+ write();
102
+ };
103
+ const restoration = history.scrollRestoration;
104
+ history.scrollRestoration = "manual";
105
+ window.addEventListener("pagehide", save);
106
+ const to = (top) => window.scrollTo({ top, left: 0, behavior: "instant" });
107
+ const toHash = (hash) => {
108
+ if (!hash || hash === "#") return false;
109
+ const target = document.getElementById(decodeURIComponent(hash.slice(1)));
110
+ target?.scrollIntoView({ behavior: "instant" });
111
+ return target !== null;
112
+ };
113
+ return {
114
+ leave: save,
115
+ arrive(location, from) {
116
+ current = location.key;
117
+ if (location.action === "pop" || from === void 0) {
118
+ const y = location.key === void 0 ? void 0 : positions[location.key];
119
+ if (y !== void 0) to(y);
120
+ return;
121
+ }
122
+ if (location.action === "replace" && location.pathname === from.pathname) return;
123
+ if (!toHash(location.hash)) to(0);
124
+ },
125
+ dispose() {
126
+ window.removeEventListener("pagehide", save);
127
+ history.scrollRestoration = restoration;
128
+ }
129
+ };
130
+ }
131
+
132
+ // src/lib/router/index.ts
75
133
  import {
76
134
  createRouter as createCoreRouter,
77
135
  createBrowserHistory,
@@ -203,7 +261,8 @@ function getSubmissionsSignal(url) {
203
261
  }
204
262
  function action(fn, name, options = {}) {
205
263
  const actionUrl = name || fn.name || "action";
206
- const submit = async (input) => {
264
+ const submit = async (...args) => {
265
+ const input = args.length > 1 ? args : args[0];
207
266
  const [, setSubmission] = getSubmissionSignal(actionUrl);
208
267
  const [, setSubmissions] = getSubmissionsSignal(actionUrl);
209
268
  let entry;
@@ -218,11 +277,10 @@ function action(fn, name, options = {}) {
218
277
  };
219
278
  const retry = () => {
220
279
  drop2();
221
- return submit(input);
280
+ return submit(...args);
222
281
  };
223
282
  entry = {
224
283
  input,
225
- // Approximate type for tracking
226
284
  pending: true,
227
285
  clear,
228
286
  retry
@@ -230,7 +288,7 @@ function action(fn, name, options = {}) {
230
288
  setSubmission(entry);
231
289
  setSubmissions((list) => [...list, entry]);
232
290
  try {
233
- const result = await fn(input);
291
+ const result = await fn(...args);
234
292
  setSubmission({
235
293
  input,
236
294
  result,
@@ -358,14 +416,49 @@ function renderLayer(m, depth, base) {
358
416
  });
359
417
  });
360
418
  };
419
+ let readsLocation;
361
420
  const [resource, { refetch }] = createResource3(() => {
362
- const result = loader({ params: base.params, location: base.location }, owner, () => cancelled, trackDeps);
421
+ const params = untrack2(base.params);
422
+ const location = untrack2(base.location);
423
+ readsLocation = false;
424
+ const result = loader(
425
+ {
426
+ params: () => params,
427
+ location: () => {
428
+ readsLocation = true;
429
+ return location;
430
+ }
431
+ },
432
+ owner,
433
+ () => cancelled,
434
+ trackDeps
435
+ );
363
436
  return router ? router.trackLoad(result) : result;
364
437
  });
365
- onCleanup(onRevalidate(() => void refetch()));
438
+ onCleanup(
439
+ onRevalidate(() => {
440
+ if (untrack2(() => base.matched()[depth]?.route) !== m.route) return;
441
+ void refetch();
442
+ })
443
+ );
444
+ let loadedFor = untrack2(() => JSON.stringify(base.matched()[depth]?.params ?? {}));
445
+ let searchedFor = untrack2(() => base.location().search);
446
+ createEffect(() => {
447
+ const now = base.matched()[depth];
448
+ const search = base.location().search;
449
+ if (now?.route !== m.route) return;
450
+ const params = JSON.stringify(now.params ?? {});
451
+ const paramsMoved = params !== loadedFor;
452
+ const searchMoved = readsLocation !== false && search !== searchedFor;
453
+ loadedFor = params;
454
+ searchedFor = search;
455
+ if (paramsMoved || searchMoved) untrack2(() => void refetch());
456
+ });
366
457
  data = () => {
367
458
  const error = resource.error;
368
459
  if (error !== void 0 && error !== null) throw error;
460
+ const r = resource;
461
+ if (r.state === "refreshing") return r.latest;
369
462
  return resource();
370
463
  };
371
464
  Object.defineProperty(data, "loading", { get: () => resource.loading });
@@ -470,16 +563,19 @@ function buildDefaultNotFound() {
470
563
  }
471
564
  var pendingTransitionMode;
472
565
  function Router(props) {
473
- const history = props.history ?? createBrowserHistory();
566
+ const history2 = props.history ?? createBrowserHistory();
474
567
  const router = createCoreRouter({
475
568
  routes: props.routes,
476
569
  base: props.base,
477
- history
570
+ history: history2
478
571
  });
479
572
  const [state, setState] = createSignal2(router.state());
480
573
  const [navigating, setNavigating] = createSignal2(false);
481
574
  const [loads, setLoads] = createSignal2(0);
482
575
  let navigationId = 0;
576
+ const scroll = history2.window ? keepScroll(router.state().location) : void 0;
577
+ let arriving = scroll ? { id: 0, location: router.state().location, from: void 0 } : null;
578
+ const [arrived, setArrived] = createSignal2(0);
483
579
  const takeMode = () => {
484
580
  const mode = pendingTransitionMode ?? (props.transition ?? "auto");
485
581
  pendingTransitionMode = void 0;
@@ -489,6 +585,10 @@ function Router(props) {
489
585
  const id = ++navigationId;
490
586
  const mode = takeMode();
491
587
  setNavigating(true);
588
+ if (scroll) {
589
+ scroll.leave();
590
+ arriving = { id, location: next.location, from: untrack2(state).location };
591
+ }
492
592
  const prepared = prepareComponents(
493
593
  (next.match?.matched ?? []).map((m) => m.component).filter(Boolean)
494
594
  );
@@ -496,6 +596,7 @@ function Router(props) {
496
596
  setState(next);
497
597
  const flushed = flush();
498
598
  if (id === navigationId) setNavigating(false);
599
+ if (scroll) setArrived(id);
499
600
  return flushed;
500
601
  };
501
602
  if (!prepared) {
@@ -509,6 +610,7 @@ function Router(props) {
509
610
  });
510
611
  onCleanup(() => {
511
612
  unsub();
613
+ scroll?.dispose();
512
614
  router.destroy();
513
615
  navigationId++;
514
616
  });
@@ -531,6 +633,19 @@ function Router(props) {
531
633
  return result;
532
634
  }
533
635
  };
636
+ if (scroll) {
637
+ const nextFrame = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (fn) => setTimeout(fn, 0);
638
+ createEffect(() => {
639
+ const id = arrived();
640
+ if (ctx.isRouting()) return;
641
+ const next = arriving;
642
+ if (!next || next.id !== id || id !== navigationId) return;
643
+ arriving = null;
644
+ nextFrame(() => {
645
+ if (id === navigationId) scroll.arrive(next.location, next.from);
646
+ });
647
+ });
648
+ }
534
649
  const root = {
535
650
  depth: -1,
536
651
  matched,
@@ -746,7 +861,7 @@ function Link(props) {
746
861
  return a;
747
862
  }
748
863
  var A = Link;
749
- function Form(props) {
864
+ function ActionForm(props) {
750
865
  const action2 = props.action;
751
866
  const cls = props.className ?? props.class;
752
867
  const url = action2.url ?? "";
@@ -786,7 +901,7 @@ async function preloadRoutes(routes, pathname) {
786
901
  }
787
902
  export {
788
903
  A,
789
- Form,
904
+ ActionForm,
790
905
  Link,
791
906
  Outlet,
792
907
  Redirect,
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/lib/router/scroll.ts
21
+ var scroll_exports = {};
22
+ __export(scroll_exports, {
23
+ keepScroll: () => keepScroll
24
+ });
25
+ module.exports = __toCommonJS(scroll_exports);
26
+ var STORE = "fx:scroll";
27
+ var KEEP = 100;
28
+ function keepScroll(initial) {
29
+ const read = () => {
30
+ try {
31
+ return JSON.parse(sessionStorage.getItem(STORE) ?? "{}");
32
+ } catch {
33
+ return {};
34
+ }
35
+ };
36
+ const positions = read();
37
+ const write = () => {
38
+ const keys = Object.keys(positions);
39
+ for (const key of keys.slice(0, Math.max(0, keys.length - KEEP))) delete positions[key];
40
+ try {
41
+ sessionStorage.setItem(STORE, JSON.stringify(positions));
42
+ } catch {
43
+ }
44
+ };
45
+ let current = initial.key;
46
+ const save = () => {
47
+ if (current === void 0) return;
48
+ delete positions[current];
49
+ positions[current] = window.scrollY;
50
+ write();
51
+ };
52
+ const restoration = history.scrollRestoration;
53
+ history.scrollRestoration = "manual";
54
+ window.addEventListener("pagehide", save);
55
+ const to = (top) => window.scrollTo({ top, left: 0, behavior: "instant" });
56
+ const toHash = (hash) => {
57
+ if (!hash || hash === "#") return false;
58
+ const target = document.getElementById(decodeURIComponent(hash.slice(1)));
59
+ target?.scrollIntoView({ behavior: "instant" });
60
+ return target !== null;
61
+ };
62
+ return {
63
+ leave: save,
64
+ arrive(location, from) {
65
+ current = location.key;
66
+ if (location.action === "pop" || from === void 0) {
67
+ const y = location.key === void 0 ? void 0 : positions[location.key];
68
+ if (y !== void 0) to(y);
69
+ return;
70
+ }
71
+ if (location.action === "replace" && location.pathname === from.pathname) return;
72
+ if (!toHash(location.hash)) to(0);
73
+ },
74
+ dispose() {
75
+ window.removeEventListener("pagehide", save);
76
+ history.scrollRestoration = restoration;
77
+ }
78
+ };
79
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The window's scroll across client navigations, as a full page load would leave it: a new
3
+ * page starts at the top, or at its `#hash`; back and forward return to where the reader
4
+ * was; a reload stays where it was.
5
+ *
6
+ * The router keeps the current page on screen until the next one has loaded its data, so
7
+ * the browser's own restoration, applied when the URL changes, would scroll the page being
8
+ * left. The router turns it off and scrolls once the next page is shown.
9
+ */
10
+ import type { RouterLocation } from '@fluixi/router';
11
+ export interface ScrollKeeper {
12
+ /** A navigation started: remember where the entry being left was scrolled to. */
13
+ leave(): void;
14
+ /** The page for `location` is on screen. */
15
+ arrive(location: RouterLocation, from: RouterLocation | undefined): void;
16
+ dispose(): void;
17
+ }
18
+ export declare function keepScroll(initial: RouterLocation): ScrollKeeper;
19
+ //# sourceMappingURL=scroll.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scroll.d.ts","sourceRoot":"","sources":["../../../../src/lib/router/scroll.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAMrD,MAAM,WAAW,YAAY;IAC3B,iFAAiF;IACjF,KAAK,IAAI,IAAI,CAAC;IACd,4CAA4C;IAC5C,MAAM,CAAC,QAAQ,EAAE,cAAc,EAAE,IAAI,EAAE,cAAc,GAAG,SAAS,GAAG,IAAI,CAAC;IACzE,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,cAAc,GAAG,YAAY,CA4DhE"}
@@ -0,0 +1,68 @@
1
+ const STORE = 'fx:scroll';
2
+ // Positions kept for this many entries, the most recent ones.
3
+ const KEEP = 100;
4
+ export function keepScroll(initial) {
5
+ const read = () => {
6
+ try {
7
+ return JSON.parse(sessionStorage.getItem(STORE) ?? '{}');
8
+ }
9
+ catch {
10
+ return {};
11
+ }
12
+ };
13
+ const positions = read();
14
+ const write = () => {
15
+ const keys = Object.keys(positions);
16
+ for (const key of keys.slice(0, Math.max(0, keys.length - KEEP)))
17
+ delete positions[key];
18
+ try {
19
+ sessionStorage.setItem(STORE, JSON.stringify(positions));
20
+ }
21
+ catch {
22
+ // Storage full or refused: positions still hold for this page's life.
23
+ }
24
+ };
25
+ let current = initial.key;
26
+ const save = () => {
27
+ if (current === undefined)
28
+ return;
29
+ delete positions[current];
30
+ positions[current] = window.scrollY;
31
+ write();
32
+ };
33
+ const restoration = history.scrollRestoration;
34
+ history.scrollRestoration = 'manual';
35
+ // A reload or a tab closed and reopened: the entry keeps its position.
36
+ window.addEventListener('pagehide', save);
37
+ // Instant, as a page load lands, whatever `scroll-behavior` the page's CSS sets: a smooth
38
+ // scroll would animate from where the last page was.
39
+ const to = (top) => window.scrollTo({ top, left: 0, behavior: 'instant' });
40
+ const toHash = (hash) => {
41
+ if (!hash || hash === '#')
42
+ return false;
43
+ const target = document.getElementById(decodeURIComponent(hash.slice(1)));
44
+ target?.scrollIntoView({ behavior: 'instant' });
45
+ return target !== null;
46
+ };
47
+ return {
48
+ leave: save,
49
+ arrive(location, from) {
50
+ current = location.key;
51
+ if (location.action === 'pop' || from === undefined) {
52
+ const y = location.key === undefined ? undefined : positions[location.key];
53
+ if (y !== undefined)
54
+ to(y);
55
+ return;
56
+ }
57
+ // A replace on the same page (a filter in the query) leaves the reader where they are.
58
+ if (location.action === 'replace' && location.pathname === from.pathname)
59
+ return;
60
+ if (!toHash(location.hash))
61
+ to(0);
62
+ },
63
+ dispose() {
64
+ window.removeEventListener('pagehide', save);
65
+ history.scrollRestoration = restoration;
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,58 @@
1
+ // src/lib/router/scroll.ts
2
+ var STORE = "fx:scroll";
3
+ var KEEP = 100;
4
+ function keepScroll(initial) {
5
+ const read = () => {
6
+ try {
7
+ return JSON.parse(sessionStorage.getItem(STORE) ?? "{}");
8
+ } catch {
9
+ return {};
10
+ }
11
+ };
12
+ const positions = read();
13
+ const write = () => {
14
+ const keys = Object.keys(positions);
15
+ for (const key of keys.slice(0, Math.max(0, keys.length - KEEP))) delete positions[key];
16
+ try {
17
+ sessionStorage.setItem(STORE, JSON.stringify(positions));
18
+ } catch {
19
+ }
20
+ };
21
+ let current = initial.key;
22
+ const save = () => {
23
+ if (current === void 0) return;
24
+ delete positions[current];
25
+ positions[current] = window.scrollY;
26
+ write();
27
+ };
28
+ const restoration = history.scrollRestoration;
29
+ history.scrollRestoration = "manual";
30
+ window.addEventListener("pagehide", save);
31
+ const to = (top) => window.scrollTo({ top, left: 0, behavior: "instant" });
32
+ const toHash = (hash) => {
33
+ if (!hash || hash === "#") return false;
34
+ const target = document.getElementById(decodeURIComponent(hash.slice(1)));
35
+ target?.scrollIntoView({ behavior: "instant" });
36
+ return target !== null;
37
+ };
38
+ return {
39
+ leave: save,
40
+ arrive(location, from) {
41
+ current = location.key;
42
+ if (location.action === "pop" || from === void 0) {
43
+ const y = location.key === void 0 ? void 0 : positions[location.key];
44
+ if (y !== void 0) to(y);
45
+ return;
46
+ }
47
+ if (location.action === "replace" && location.pathname === from.pathname) return;
48
+ if (!toHash(location.hash)) to(0);
49
+ },
50
+ dispose() {
51
+ window.removeEventListener("pagehide", save);
52
+ history.scrollRestoration = restoration;
53
+ }
54
+ };
55
+ }
56
+ export {
57
+ keepScroll
58
+ };
@@ -1,4 +1,4 @@
1
- /*! @fluixi/core v1.0.0-alpha.94 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/core v1.0.0-alpha.96 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -1,4 +1,4 @@
1
- /*! @fluixi/core v1.0.0-alpha.94 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/core v1.0.0-alpha.96 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
2
 
3
3
  // src/lib/server/hydration.ts
4
4
  import { isServer as domIsServer } from "@fluixi/dom";
@@ -1,4 +1,4 @@
1
- /*! @fluixi/core v1.0.0-alpha.94 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/core v1.0.0-alpha.96 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -1,4 +1,4 @@
1
- /*! @fluixi/core v1.0.0-alpha.94 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/core v1.0.0-alpha.96 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
2
 
3
3
  // src/lib/storage/index.ts
4
4
  import { appendResponseHeader, getRequestContext, getServerData } from "@fluixi/dom";
@@ -1,4 +1,4 @@
1
- /*! @fluixi/core v1.0.0-alpha.94 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/core v1.0.0-alpha.96 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;