@jk2908/solas 0.5.2 → 0.5.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.4 - 2026-06-08
4
+
5
+ - Add a BrowserRouterHistory API to sync app back/forward navigation with browser navigation.
6
+ - Expose BrowserRouterContext.history for gated use in client components
7
+
8
+ ## 0.5.3 - 2026-06-06
9
+
10
+ - Ensured `createRuntime(...)` is exported from `@jk2908/solas/env/rsc` for generated RSC entry compatibility.
11
+ - Clarified hotfix packaging so generated environment code can always import `createRuntime` from the public `env/rsc` export path.
12
+
3
13
  ## 0.5.2 - 2026-06-06
4
14
 
5
15
  - Fixed browser bundling regressions caused by exposing runtime creation through `Solas.Runtime.create(...)` in the shared `$` export path.
@@ -0,0 +1,46 @@
1
+ import type { BrowserRouter } from './shared.js';
2
+ /**
3
+ * A small in-memory mirror of browser history used by BrowserRouter.
4
+ * It keeps local entries/index in sync with push/replace/go and
5
+ * popstate updates
6
+ */
7
+ export declare class BrowserRouterHistory {
8
+ #private;
9
+ constructor(url: {
10
+ pathname: string;
11
+ search?: string;
12
+ hash?: string;
13
+ });
14
+ /**
15
+ * Returns a copy of tracked entries
16
+ */
17
+ get entries(): BrowserRouter.HistoryEntry[];
18
+ /**
19
+ * Returns the tracked history index
20
+ */
21
+ get index(): number;
22
+ /**
23
+ * Returns the tracked history length
24
+ */
25
+ get length(): number;
26
+ /**
27
+ * Returns the current tracked entry
28
+ */
29
+ get current(): BrowserRouter.HistoryEntry;
30
+ /**
31
+ * Pushes a new history entry and advances the index
32
+ */
33
+ pushState(path: string): BrowserRouter.HistoryEntry;
34
+ /**
35
+ * Replaces the current history entry without changing length
36
+ */
37
+ replaceState(path: string): BrowserRouter.HistoryEntry;
38
+ /**
39
+ * Applies relative history movement and updates local index
40
+ */
41
+ go(delta: number): BrowserRouter.HistoryEntry;
42
+ /**
43
+ * Syncs local index and entry from a popstate event
44
+ */
45
+ onPopState(): BrowserRouter.HistoryEntry;
46
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A small in-memory mirror of browser history used by BrowserRouter.
3
+ * It keeps local entries/index in sync with push/replace/go and
4
+ * popstate updates
5
+ */
6
+ export class BrowserRouterHistory {
7
+ #entries;
8
+ #index;
9
+ constructor(url) {
10
+ const entry = {
11
+ pathname: url.pathname,
12
+ search: url.search ?? '',
13
+ hash: url.hash ?? '',
14
+ };
15
+ this.#entries = [entry];
16
+ this.#index = 0;
17
+ }
18
+ /**
19
+ * Returns a copy of tracked entries
20
+ */
21
+ get entries() {
22
+ return [...this.#entries];
23
+ }
24
+ /**
25
+ * Returns the tracked history index
26
+ */
27
+ get index() {
28
+ return this.#index;
29
+ }
30
+ /**
31
+ * Returns the tracked history length
32
+ */
33
+ get length() {
34
+ return this.#entries.length;
35
+ }
36
+ /**
37
+ * Returns the current tracked entry
38
+ */
39
+ get current() {
40
+ return this.#entries[this.#index] ?? null;
41
+ }
42
+ /**
43
+ * Pushes a new history entry and advances the index
44
+ */
45
+ pushState(path) {
46
+ if (this.#index < this.#entries.length - 1) {
47
+ // drop forward entries when pushing from the middle
48
+ this.#entries = this.#entries.slice(0, this.#index + 1);
49
+ }
50
+ window.history.pushState(null, '', path);
51
+ const entry = {
52
+ pathname: window.location.pathname,
53
+ search: window.location.search,
54
+ hash: window.location.hash,
55
+ };
56
+ this.#entries.push(entry);
57
+ this.#index++;
58
+ return entry;
59
+ }
60
+ /**
61
+ * Replaces the current history entry without changing length
62
+ */
63
+ replaceState(path) {
64
+ window.history.replaceState(null, '', path);
65
+ const entry = {
66
+ pathname: window.location.pathname,
67
+ search: window.location.search,
68
+ hash: window.location.hash,
69
+ };
70
+ if (this.#entries.length === 0) {
71
+ this.#entries = [entry];
72
+ this.#index = 0;
73
+ return entry;
74
+ }
75
+ this.#entries[this.#index] = entry;
76
+ return entry;
77
+ }
78
+ /**
79
+ * Applies relative history movement and updates local index
80
+ */
81
+ go(delta) {
82
+ if (!Number.isInteger(delta) || delta === 0) {
83
+ return this.current;
84
+ }
85
+ const nextIndex = Math.max(0, Math.min(this.#index + delta, this.#entries.length - 1));
86
+ if (nextIndex !== this.#index)
87
+ this.#index = nextIndex;
88
+ window.history.go(delta);
89
+ return this.current;
90
+ }
91
+ /**
92
+ * Syncs local index and entry from a popstate event
93
+ */
94
+ onPopState() {
95
+ const current = {
96
+ pathname: window.location.pathname,
97
+ search: window.location.search,
98
+ hash: window.location.hash,
99
+ };
100
+ const prev = this.#entries[this.#index - 1];
101
+ const next = this.#entries[this.#index + 1];
102
+ if (prev &&
103
+ prev.pathname === current.pathname &&
104
+ prev.search === current.search &&
105
+ prev.hash === current.hash) {
106
+ this.#index--;
107
+ this.#entries[this.#index] = current;
108
+ return current;
109
+ }
110
+ if (next &&
111
+ next.pathname === current.pathname &&
112
+ next.search === current.search &&
113
+ next.hash === current.hash) {
114
+ this.#index++;
115
+ this.#entries[this.#index] = current;
116
+ return current;
117
+ }
118
+ // fallback: locate an existing matching entry
119
+ const index = this.#entries.findIndex(entry => entry.pathname === current.pathname &&
120
+ entry.search === current.search &&
121
+ entry.hash === current.hash);
122
+ if (index >= 0) {
123
+ this.#index = index;
124
+ this.#entries[index] = current;
125
+ return current;
126
+ }
127
+ this.#entries.push(current);
128
+ this.#index = this.#entries.length - 1;
129
+ return current;
130
+ }
131
+ }
@@ -1,22 +1,14 @@
1
1
  import type { RscPayload } from '../env/rsc.js';
2
2
  import { BrowserRouter } from './shared.js';
3
3
  export { BrowserRouter } from './shared.js';
4
- export declare const BrowserRouterContext: import("react").Context<{
5
- go: BrowserRouter.Go;
6
- prefetch: (path: string) => void;
7
- refresh: BrowserRouter.Refresh;
8
- isNavigating: boolean;
9
- url: {
10
- pathname?: string | undefined;
11
- search?: string | undefined;
12
- };
13
- }>;
4
+ export declare const BrowserRouterContext: import("react").Context<BrowserRouter.Context>;
14
5
  export declare function BrowserRouterProvider({ children, setPayload, isNavigating, url }: {
15
6
  children: React.ReactNode;
16
7
  setPayload?: (payload: RscPayload) => void;
17
8
  isNavigating?: boolean;
18
- url?: {
19
- pathname?: string;
9
+ url: {
10
+ pathname: string;
20
11
  search?: string;
12
+ hash?: string;
21
13
  };
22
14
  }): import("react/jsx-runtime").JSX.Element;
@@ -4,6 +4,7 @@ import { createContext, useCallback, useEffect, useMemo, useRef } from 'react';
4
4
  import { createFromFetch } from '@vitejs/plugin-rsc/browser';
5
5
  import { Logger } from '../../utils/logger.js';
6
6
  import { Solas } from '../../solas.js';
7
+ import { BrowserRouterHistory } from './history.js';
7
8
  import { ResponseCache } from './response-cache.js';
8
9
  import { BrowserRouter } from './shared.js';
9
10
  export { BrowserRouter } from './shared.js';
@@ -12,7 +13,15 @@ export const BrowserRouterContext = createContext({
12
13
  prefetch: () => { },
13
14
  refresh: async () => '',
14
15
  isNavigating: false,
15
- url: {},
16
+ url: {
17
+ pathname: '',
18
+ search: '',
19
+ hash: '',
20
+ },
21
+ history: {
22
+ entries: [],
23
+ index: -1,
24
+ },
16
25
  });
17
26
  const DEFAULT_GO_CONFIG = {
18
27
  replace: false,
@@ -22,6 +31,7 @@ const responseCache = new ResponseCache();
22
31
  export function BrowserRouterProvider({ children, setPayload, isNavigating = false, url, }) {
23
32
  const id = useRef(0);
24
33
  const controller = useRef(null);
34
+ const history = useRef(new BrowserRouterHistory(url));
25
35
  /**
26
36
  * Navigates to a given path
27
37
  *
@@ -35,6 +45,13 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
35
45
  const navigationId = id.current;
36
46
  const currentPath = window.location.pathname + window.location.search;
37
47
  let path = currentPath;
48
+ // support numeric relative navigation like router.go(-1) to go back,
49
+ // or router.go(1) to go forward. Will trigger a popstate event which
50
+ // is intercepted
51
+ if (typeof to === 'number') {
52
+ const entry = history.current.go(to);
53
+ return `${entry.pathname}${entry.search}`;
54
+ }
38
55
  const replace = opts?.replace ?? DEFAULT_GO_CONFIG.replace;
39
56
  controller.current?.abort();
40
57
  controller.current = null;
@@ -51,10 +68,10 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
51
68
  path = key;
52
69
  if (path !== currentPath) {
53
70
  if (replace) {
54
- window.history.replaceState(null, '', path);
71
+ history.current.replaceState(path);
55
72
  }
56
73
  else {
57
- window.history.pushState(null, '', path);
74
+ history.current.pushState(path);
58
75
  }
59
76
  }
60
77
  let promise = responseCache.get(path);
@@ -78,7 +95,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
78
95
  if (navigationId !== id.current)
79
96
  return resolvedPath;
80
97
  if (resolvedPath !== path) {
81
- window.history.replaceState(null, '', resolvedPath);
98
+ history.current.replaceState(resolvedPath);
82
99
  }
83
100
  setPayload?.(payload);
84
101
  window.dispatchEvent(new CustomEvent(Solas.Events.names.NAVIGATION, {
@@ -138,9 +155,13 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
138
155
  });
139
156
  }, [go]);
140
157
  useEffect(() => {
141
- const handler = () => go(BrowserRouter.toTarget(window.location.pathname + window.location.search), {
142
- replace: true,
143
- });
158
+ const h = history.current;
159
+ function handler() {
160
+ h.onPopState();
161
+ void go(BrowserRouter.toTarget(window.location.pathname + window.location.search), {
162
+ replace: true,
163
+ });
164
+ }
144
165
  window.addEventListener('popstate', handler);
145
166
  return () => {
146
167
  controller.current?.abort();
@@ -154,8 +175,13 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
154
175
  refresh,
155
176
  isNavigating,
156
177
  url: {
157
- pathname: url?.pathname,
178
+ pathname: url.pathname,
158
179
  search: url?.search,
180
+ hash: url?.hash,
181
+ },
182
+ history: {
183
+ entries: history.current.entries,
184
+ index: history.current.index,
159
185
  },
160
186
  }), [go, prefetch, refresh, isNavigating, url]);
161
187
  return _jsx(BrowserRouterContext, { value: value, children: children });
@@ -11,6 +11,26 @@ export declare namespace BrowserRouter {
11
11
  query?: Query;
12
12
  params?: Params;
13
13
  };
14
+ export type Context = {
15
+ go: BrowserRouter.Go;
16
+ prefetch: (path: string) => void;
17
+ refresh: BrowserRouter.Refresh;
18
+ isNavigating: boolean;
19
+ url: {
20
+ pathname: string;
21
+ search?: string;
22
+ hash?: string;
23
+ };
24
+ history: {
25
+ entries: HistoryEntry[];
26
+ index: number;
27
+ };
28
+ };
29
+ export type HistoryEntry = {
30
+ pathname: string;
31
+ search: string;
32
+ hash: string;
33
+ };
14
34
  /**
15
35
  * These targets are used as-is. They are not matched against the route table,
16
36
  * so this covers normal external URLs and hash-only links
@@ -152,6 +172,7 @@ export declare namespace BrowserRouter {
152
172
  * ```
153
173
  */
154
174
  export type Go = {
175
+ (to: number): Promise<string>;
155
176
  <TTo extends Path>(to: TTo, opts?: PatternConfig<TTo> & Replace): Promise<string>;
156
177
  <TTo extends Target>(to: TTo, opts?: TargetConfig & Replace): Promise<string>;
157
178
  <TTo extends string>(to: string extends TTo ? TTo : never, opts?: GoOptions): Promise<string>;
@@ -1,10 +1 @@
1
- export declare function useRouter(): {
2
- go: import("./shared.js").BrowserRouter.Go;
3
- prefetch: (path: string) => void;
4
- refresh: import("./shared.js").BrowserRouter.Refresh;
5
- isNavigating: boolean;
6
- url: {
7
- pathname?: string | undefined;
8
- search?: string | undefined;
9
- };
10
- };
1
+ export declare function useRouter(): import("./shared.js").BrowserRouter.Context;
@@ -12,9 +12,10 @@ export type RscPayload = {
12
12
  formState?: ReactFormState;
13
13
  root: React.ReactNode;
14
14
  metadata?: Promise<Metadata.Item>;
15
- url?: {
16
- pathname?: string;
15
+ url: {
16
+ pathname: string;
17
17
  search?: string;
18
+ hash?: string;
18
19
  };
19
20
  };
20
21
  /**
@@ -65,6 +65,7 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
65
65
  url: {
66
66
  pathname: url.pathname,
67
67
  search: url.search,
68
+ hash: url.hash,
68
69
  },
69
70
  };
70
71
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "A Vite + React meta-framework exploring streaming, Server Components, and partial prerendering. Designed for simplicity and lightness",
5
5
  "keywords": [
6
6
  "framework",
@@ -1,13 +0,0 @@
1
- import { RuntimeBase } from '../internal/runtimes/runtime.js';
2
- declare class BunAdapter extends RuntimeBase {
3
- readonly name: "bun";
4
- readonly module: string;
5
- exists(filePath: string): Promise<boolean>;
6
- readText(filePath: string): Promise<string>;
7
- readBuffer(filePath: string): Promise<ArrayBuffer>;
8
- mimeType(filePath: string): string;
9
- write(filePath: string, content: string | NodeJS.ArrayBufferView): Promise<void>;
10
- hash(value: string): string;
11
- }
12
- export default function bunAdapter(): BunAdapter;
13
- export {};
@@ -1,39 +0,0 @@
1
- import { RuntimeBase } from '../internal/runtimes/runtime.js';
2
- import { Solas } from '../solas.js';
3
- class BunAdapter extends RuntimeBase {
4
- name = 'bun';
5
- module = `${Solas.Config.PKG_NAME}/runtimes/bun`;
6
- async exists(filePath) {
7
- return Bun.file(filePath).exists();
8
- }
9
- readText(filePath) {
10
- return Bun.file(filePath).text();
11
- }
12
- readBuffer(filePath) {
13
- return Bun.file(filePath).arrayBuffer();
14
- }
15
- mimeType(filePath) {
16
- return Bun.file(filePath).type || 'application/octet-stream';
17
- }
18
- async write(filePath, content) {
19
- // normalise wider arraybuffer views into a shape Bun.write accepts directly
20
- await Bun.write(filePath, typeof content === 'string'
21
- ? content
22
- : content instanceof Uint8Array
23
- ? content
24
- : new Uint8Array(content.buffer, content.byteOffset, content.byteLength));
25
- }
26
- hash(value) {
27
- const hash = Bun.hash(value);
28
- // Bun.hash returns an integer-like value, so keep it in BigInt space and avoid
29
- // precision loss. Clamp it to an unsigned 64-bit value before hex
30
- // formatting. Pad to 16 chars to match the Node adapter
31
- // output shape
32
- return BigInt.asUintN(64, typeof hash === 'bigint' ? hash : BigInt(hash))
33
- .toString(16)
34
- .padStart(16, '0');
35
- }
36
- }
37
- export default function bunAdapter() {
38
- return new BunAdapter();
39
- }