@jk2908/solas 0.4.3 → 0.4.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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.4 - 2026-05-29
4
+
5
+ - Added `router.refresh()` to the browser router, and made it clear that it clears the current route cache before fetching a fresh RSC payload.
6
+ - Reworked browser-router response caching so prefetched RSC responses can be reused by later navigations without a second fetch.
7
+ - Documented client routing and generated route typing in the README, including `useRouter()`, `router.go()`, `router.prefetch()`, `router.refresh()`, `Link` prefetch behaviour, and typed `Route.Metadata`/`Route.StaticParams` usage.
8
+ - Added a refresh demo route to the basic example app for manual regression testing.
9
+
3
10
  ## 0.4.3 - 2026-05-27
4
11
 
5
12
  - Updated README docs to show that `dynamic()` must be awaited in request-time deferred `ppr` usage examples.
package/README.md CHANGED
@@ -75,6 +75,100 @@ If a route has both `+page.tsx` and `+endpoint.ts`, Solas selects the GET handle
75
75
 
76
76
  Non-GET methods (`POST`, `PUT`, `PATCH`, `DELETE`) always run `+endpoint.ts`.
77
77
 
78
+ ## Client Routing
79
+
80
+ Import client navigation helpers from `@jk2908/solas/router`.
81
+
82
+ ```tsx
83
+ import { Link, useRouter } from '@jk2908/solas/router'
84
+ ```
85
+
86
+ Solas generates route types for your app. `Link` and `router.go(...)` use those generated route types for autocomplete and type checking:
87
+
88
+ ```tsx
89
+ <Link href="/posts" />
90
+ <Link href="/p/:id" params={{ id: 'post-1' }} />
91
+
92
+ await router.go('/posts')
93
+ await router.go('/p/:id', { params: { id: 'post-1' } })
94
+ ```
95
+
96
+ That gives you:
97
+
98
+ - autocomplete for known route paths
99
+ - required params for dynamic routes like `/p/:id`
100
+ - rejected params for static routes that do not accept them
101
+ - typed query and navigation options on `router.go(...)`
102
+
103
+ Use `Link` for same-origin app navigation. Prefetching is opt-in:
104
+
105
+ ```tsx
106
+ <Link href="/posts">Posts</Link>
107
+ <Link href="/posts" prefetch="intent">Prefetch on focus or touch</Link>
108
+ <Link href="/posts" prefetch="hover">Prefetch on hover</Link>
109
+ <Link href="/p/:id" params={{ id: 'post-1' }}>Typed params</Link>
110
+ ```
111
+
112
+ `prefetch="none"` is the default. Solas does not automatically prefetch routes unless you opt in with `Link` or call `router.prefetch(...)` yourself.
113
+
114
+ Use `useRouter()` inside client components for programmatic navigation, prefetching, and refreshing the current route:
115
+
116
+ ```tsx
117
+ 'use client'
118
+
119
+ import { useRouter } from '@jk2908/solas/router'
120
+
121
+ export function Controls() {
122
+ const router = useRouter()
123
+
124
+ return (
125
+ <>
126
+ <button type="button" onClick={() => void router.go('/posts')}>
127
+ Go to posts
128
+ </button>
129
+
130
+ <button type="button" onMouseEnter={() => router.prefetch('/posts')}>
131
+ Prefetch posts
132
+ </button>
133
+
134
+ <button type="button" onClick={() => void router.refresh()}>
135
+ Refresh current route
136
+ </button>
137
+ </>
138
+ )
139
+ }
140
+ ```
141
+
142
+ `router.go(...)` accepts route params and query values using the same typed route rules as `Link`:
143
+
144
+ ```tsx
145
+ await router.go('/p/:id', {
146
+ params: { id: 'post-2' },
147
+ query: { draft: true },
148
+ })
149
+ ```
150
+
151
+ Those same generated route types can also be reused outside navigation helpers when you want route params to stay typed in page exports:
152
+
153
+ ```tsx
154
+ import type { Route, Solas } from '@jk2908/solas'
155
+
156
+ export const metadata: Route.Metadata<Solas.Routes['/writing/:slug']> = ({ params }) => {
157
+ const post = allPosts?.find(p => p.__mdsrc.slug === params?.slug)
158
+
159
+ return {
160
+ title: post?.title ?? 'Post not found',
161
+ }
162
+ }
163
+
164
+ export const params: Route.StaticParams<Solas.Routes['/writing/:slug']> = () =>
165
+ allPosts?.map(p => ({ slug: p.__mdsrc.slug })) ?? []
166
+ ```
167
+
168
+ That keeps your route params aligned across links, imperative navigation, metadata, and static params.
169
+
170
+ `router.refresh()` clears the cached RSC response for the current path and fetches a fresh payload, so it is most useful for routes that render request-time data. `router.isNavigating` exposes pending client-side navigation state.
171
+
78
172
  ## Config
79
173
 
80
174
  All Solas options are passed to `solas()` inside `defineConfig`.
@@ -16,6 +16,8 @@ function guard(path, prefetcher) {
16
16
  export function Link({ children, href, params, prefetch = 'none', query, ...rest }) {
17
17
  const { go, prefetch: prefetcher } = useRouter();
18
18
  const timer = useRef(null);
19
+ // track whether the link is meant to be handled by the router, to avoid
20
+ // unnecessary prefetching and event handling for external links
19
21
  const handled = useRef(false);
20
22
  const target = BrowserRouter.toTarget(href, params, query);
21
23
  // clear any pending hover-prefetch timer on unmount
@@ -1,10 +1,17 @@
1
- export declare namespace Prefetcher {
1
+ export declare namespace ResponseCache {
2
2
  type Entry = {
3
3
  promise: Promise<Response>;
4
4
  timeoutId: ReturnType<typeof setTimeout>;
5
5
  };
6
6
  }
7
- export declare class Prefetcher {
7
+ /**
8
+ * A simple in-memory cache for RSC response promises used by the BrowserRouter.
9
+ * It lets a later navigation reuse a prefetched response for the same path,
10
+ * and helps avoid issuing a second fetch when navigation follows shortly
11
+ * after prefetch. Entries are stored by normalised path with TTL and
12
+ * max size eviction
13
+ */
14
+ export declare class ResponseCache {
8
15
  #private;
9
16
  ttl: number;
10
17
  maxSize: number;
@@ -16,7 +23,7 @@ export declare class Prefetcher {
16
23
  * Converts a url path to a cache key by normalising it
17
24
  * against a base url
18
25
  */
19
- static key(path: string, base: string): string | null;
26
+ static toCacheKey(path: string, base: string): string | null;
20
27
  /**
21
28
  * Evicts the oldest entry from the cache
22
29
  */
@@ -26,8 +33,8 @@ export declare class Prefetcher {
26
33
  */
27
34
  has(path: string): boolean;
28
35
  /**
29
- * Retrieves a fresh response promise for the given path if it exists
30
- * by cloning the cached response so each consumer gets an unread stream
36
+ * Retrieves a fresh response promise for the given path if it exists by
37
+ * cloning the cached response so each consumer gets an unread stream
31
38
  */
32
39
  get(path: string): Promise<Response> | undefined;
33
40
  /**
@@ -1,4 +1,11 @@
1
- export class Prefetcher {
1
+ /**
2
+ * A simple in-memory cache for RSC response promises used by the BrowserRouter.
3
+ * It lets a later navigation reuse a prefetched response for the same path,
4
+ * and helps avoid issuing a second fetch when navigation follows shortly
5
+ * after prefetch. Entries are stored by normalised path with TTL and
6
+ * max size eviction
7
+ */
8
+ export class ResponseCache {
2
9
  #cache = new Map();
3
10
  ttl = 60_000;
4
11
  maxSize = 32;
@@ -10,7 +17,7 @@ export class Prefetcher {
10
17
  * Converts a url path to a cache key by normalising it
11
18
  * against a base url
12
19
  */
13
- static key(path, base) {
20
+ static toCacheKey(path, base) {
14
21
  try {
15
22
  const url = new URL(path, base);
16
23
  // hash is client-only and never sent to the server, so exclude it
@@ -40,8 +47,8 @@ export class Prefetcher {
40
47
  return this.#cache.has(path);
41
48
  }
42
49
  /**
43
- * Retrieves a fresh response promise for the given path if it exists
44
- * by cloning the cached response so each consumer gets an unread stream
50
+ * Retrieves a fresh response promise for the given path if it exists by
51
+ * cloning the cached response so each consumer gets an unread stream
45
52
  */
46
53
  get(path) {
47
54
  const promise = this.#cache.get(path)?.promise;
@@ -4,6 +4,7 @@ export { BrowserRouter } from './shared.js';
4
4
  export declare const BrowserRouterContext: import("react").Context<{
5
5
  go: BrowserRouter.Go;
6
6
  prefetch: (path: string) => void;
7
+ refresh: () => void;
7
8
  isNavigating: boolean;
8
9
  url: {
9
10
  pathname?: string | undefined;
@@ -4,12 +4,13 @@ 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 { Prefetcher } from '../prefetcher.js';
7
+ import { ResponseCache } from './response-cache.js';
8
8
  import { BrowserRouter } from './shared.js';
9
9
  export { BrowserRouter } from './shared.js';
10
10
  export const BrowserRouterContext = createContext({
11
11
  go: async () => '',
12
12
  prefetch: () => { },
13
+ refresh: () => { },
13
14
  isNavigating: false,
14
15
  url: {},
15
16
  });
@@ -17,10 +18,18 @@ const DEFAULT_GO_CONFIG = {
17
18
  replace: false,
18
19
  };
19
20
  const logger = new Logger();
20
- const prefetcher = new Prefetcher();
21
+ const responseCache = new ResponseCache();
21
22
  export function BrowserRouterProvider({ children, setPayload, isNavigating = false, url, }) {
22
23
  const id = useRef(0);
23
24
  const controller = useRef(null);
25
+ /**
26
+ * Navigates to a given path
27
+ *
28
+ * @param to - the target path to navigate to, which can be a route pattern with params or an external URL
29
+ * @param opts - options for navigation, including whether to replace the current history entry and pass query
30
+ * and route params
31
+ * @returns the final path navigated to after any redirects, or the original path if navigation failed
32
+ */
24
33
  const go = useCallback(async (to, opts = {}) => {
25
34
  id.current += 1;
26
35
  const navigationId = id.current;
@@ -36,7 +45,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
36
45
  throw new Error('[router.go]: external URLs are not supported. Use <a> instead');
37
46
  }
38
47
  const url = new URL(target, window.location.origin);
39
- const key = Prefetcher.key(url.toString(), window.location.origin);
48
+ const key = ResponseCache.toCacheKey(url.toString(), window.location.origin);
40
49
  if (!key)
41
50
  throw new Error('Invalid navigation url');
42
51
  path = key;
@@ -48,7 +57,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
48
57
  window.history.pushState(null, '', path);
49
58
  }
50
59
  }
51
- let promise = prefetcher.get(path);
60
+ let promise = responseCache.get(path);
52
61
  existing = promise !== undefined;
53
62
  if (!promise) {
54
63
  const ctrl = new AbortController();
@@ -57,7 +66,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
57
66
  headers: { accept: 'text/x-component' },
58
67
  signal: ctrl.signal,
59
68
  });
60
- prefetcher.set(path, promise);
69
+ responseCache.set(path, promise);
61
70
  }
62
71
  if (navigationId !== id.current)
63
72
  return path;
@@ -65,7 +74,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
65
74
  promise,
66
75
  createFromFetch(promise),
67
76
  ]);
68
- const resolvedPath = Prefetcher.key(res.url, window.location.origin) ?? path;
77
+ const resolvedPath = ResponseCache.toCacheKey(res.url, window.location.origin) ?? path;
69
78
  if (navigationId !== id.current)
70
79
  return resolvedPath;
71
80
  if (resolvedPath !== path) {
@@ -92,20 +101,42 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
92
101
  finally {
93
102
  if (navigationId === id.current)
94
103
  controller.current = null;
95
- if (!existing) {
96
- prefetcher.remove(path);
97
- }
104
+ if (!existing)
105
+ responseCache.remove(path);
98
106
  }
99
107
  return path;
100
108
  }, [setPayload]);
109
+ /**
110
+ * Prefetches the RSC response for a given path and caches it for later navigation.
111
+ * Does nothing if a cached response already exists for the path
112
+ *
113
+ * @param path - the target path to prefetch
114
+ * @returns void
115
+ */
101
116
  const prefetch = useCallback((path) => {
102
- const key = Prefetcher.key(path, window.location.origin);
117
+ const key = ResponseCache.toCacheKey(path, window.location.origin);
103
118
  if (!key)
104
119
  return;
105
- if (prefetcher.has(key))
120
+ if (responseCache.has(key))
106
121
  return;
107
- prefetcher.set(key, fetch(key, { headers: { Accept: 'text/x-component' } }));
122
+ responseCache.set(key, fetch(key, { headers: { Accept: 'text/x-component' } }));
108
123
  }, []);
124
+ /**
125
+ * Refreshes the current page by re-fetching the RSC response for the current path and updating the
126
+ * payload. It also clears any cached response for the current path to ensure that the latest
127
+ * version is fetched
128
+ */
129
+ const refresh = useCallback(() => {
130
+ const currentPath = window.location.pathname + window.location.search;
131
+ const key = ResponseCache.toCacheKey(currentPath, window.location.origin);
132
+ if (!key)
133
+ return;
134
+ if (responseCache.has(key))
135
+ responseCache.remove(key);
136
+ return go(currentPath, {
137
+ replace: true,
138
+ });
139
+ }, [go]);
109
140
  useEffect(() => {
110
141
  const handler = () => go(BrowserRouter.toTarget(window.location.pathname + window.location.search), {
111
142
  replace: true,
@@ -120,11 +151,12 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
120
151
  const value = useMemo(() => ({
121
152
  go,
122
153
  prefetch,
154
+ refresh,
123
155
  isNavigating,
124
156
  url: {
125
157
  pathname: url?.pathname,
126
158
  search: url?.search,
127
159
  },
128
- }), [go, prefetch, isNavigating, url]);
160
+ }), [go, prefetch, refresh, isNavigating, url]);
129
161
  return _jsx(BrowserRouterContext, { value: value, children: children });
130
162
  }
@@ -24,6 +24,7 @@ var BrowserRouter;
24
24
  */
25
25
  function toTarget(path, params, query) {
26
26
  const used = new Set();
27
+ // first, replace all the :param parts with the corresponding params
27
28
  let to = path.replaceAll(/:([A-Za-z0-9_]+)/g, (_, key) => {
28
29
  const value = params?.[key];
29
30
  if (value == null) {
@@ -1,6 +1,7 @@
1
1
  export declare function useRouter(): {
2
2
  go: import("./shared.js").BrowserRouter.Go;
3
3
  prefetch: (path: string) => void;
4
+ refresh: () => void;
4
5
  isNavigating: boolean;
5
6
  url: {
6
7
  pathname?: string | undefined;
@@ -37,8 +37,8 @@ export function writeMaps(imports, modules) {
37
37
  parts.push(`endpoint: ${toIdentifier(m.endpointId, `endpoint id for ${moduleId}`)}`);
38
38
  }
39
39
  if (m['401Ids']?.length) {
40
- const unauthorized = toIdentifierList(m['401Ids'], `401s for ${moduleId}`);
41
- parts.push(`'401s': [${unauthorized}]`);
40
+ const unauthorised = toIdentifierList(m['401Ids'], `401s for ${moduleId}`);
41
+ parts.push(`'401s': [${unauthorised}]`);
42
42
  }
43
43
  if (m['403Ids']?.length) {
44
44
  const forbidden = toIdentifierList(m['403Ids'], `403s for ${moduleId}`);
@@ -70,13 +70,15 @@ export function writeMaps(imports, modules) {
70
70
  ${AUTOGEN_MSG}
71
71
 
72
72
  import type { ImportMap } from '${Solas.Config.PKG_NAME}'
73
- ${importLines
73
+
74
+ ${importLines
74
75
  ? `
75
- ${importLines}`
76
+ ${importLines}
77
+ `
76
78
  : ''}
77
79
 
78
80
  export const importMap = {
79
- ${entries}
81
+ ${entries}
80
82
  } as const satisfies ImportMap
81
83
  `;
82
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.4.3",
3
+ "version": "0.4.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",