@rshono/core 1.0.0-rc.28 → 1.0.0-rc.29

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.
@@ -0,0 +1,155 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ /**
3
+ * Whether the browser runs `precommitHandler` before committing. `NavigationPrecommitController` is exposed
4
+ * alongside it; a browser that lacks the option ignores the unknown dictionary member, which would leave a
5
+ * `intercept()` with no handler at all — a committed URL and nothing rendered. The caller keeps the ordinary
6
+ * post-commit handler there, which is the behaviour every other browser gets.
7
+ */
8
+ const canPrecommit = typeof NavigationPrecommitController !== 'undefined';
9
+ /** The prepared navigation that has not committed yet; what {@link useNavigationPreparation} observes. */
10
+ let active;
11
+ const subscribers = new Set();
12
+ const notify = () => {
13
+ for (const listener of subscribers)
14
+ listener();
15
+ };
16
+ const subscribe = (listener) => {
17
+ subscribers.add(listener);
18
+ return () => {
19
+ subscribers.delete(listener);
20
+ };
21
+ };
22
+ /** Preparation is pending until the URL commits and the runtime takes ownership of its response. */
23
+ export function useNavigationPreparation() {
24
+ return useSyncExternalStore(subscribe, () => active !== undefined, () => false);
25
+ }
26
+ /** An action or a newer navigation must not commit an older prepared snapshot. */
27
+ export function cancelNavigationPreparation() {
28
+ const preparation = active;
29
+ if (!preparation)
30
+ return;
31
+ active = undefined;
32
+ preparation.detach();
33
+ preparation.controller.abort();
34
+ notify();
35
+ }
36
+ /** The events this listener claimed: their scroll, focus and payload handler belong to the preparation. */
37
+ const claimed = new WeakSet();
38
+ /** Whether the preparation listener claimed this event; `onNavigate` must then register no handler of its own. */
39
+ export function isPreparedNavigation(event) {
40
+ return claimed.has(event);
41
+ }
42
+ /**
43
+ * Prepares push and replace navigations before the browser commits them.
44
+ *
45
+ * The payload is fetched from a `precommitHandler`, so the URL, the history entry and the rendered page
46
+ * change together: the ordinary path commits the URL first and can only recover from a failed fetch with a
47
+ * real document load. A payload `redirect()` is resolved here too, which keeps the redirect stub out of
48
+ * history, and the payload reaches the runtime through `controller.addHandler()`, which runs after the
49
+ * commit like any other navigation handler — the same place the post-commit loader would have run.
50
+ *
51
+ * A failure after the event's signal has aborted is supersession, and the navigation that replaced this one
52
+ * owns the screen. Anything else — a refused response, a redirect loop, a redirect this document cannot
53
+ * commit — is a real failure: the caller is asked for a document load rather than aborting into silence.
54
+ */
55
+ export function listenNavigationPreparation({ fetchPayload, shouldIntercept, canCommitInPlace, apply, fallbackToDocument, }) {
56
+ if (!canPrecommit)
57
+ return () => { };
58
+ const onNavigate = (event) => {
59
+ if (event.navigationType !== 'push' && event.navigationType !== 'replace')
60
+ return;
61
+ if (!shouldIntercept(event))
62
+ return;
63
+ event.intercept({
64
+ scroll: 'manual',
65
+ focusReset: event.navigationType === 'replace' ? 'manual' : 'after-transition',
66
+ precommitHandler: async (controller) => {
67
+ cancelNavigationPreparation();
68
+ let href = event.destination.url;
69
+ const visited = new Set();
70
+ let preparation;
71
+ let recovery;
72
+ try {
73
+ for (;;) {
74
+ if (visited.has(href) || visited.size >= 10)
75
+ throw new Error(`[rshono] the payload for ${event.destination.url} redirects in a loop`);
76
+ visited.add(href);
77
+ const requestController = new AbortController();
78
+ const abort = () => {
79
+ requestController.abort(event.signal.reason);
80
+ if (active === preparation) {
81
+ active = undefined;
82
+ notify();
83
+ }
84
+ };
85
+ const next = {
86
+ href,
87
+ fetched: fetchPayload(href, requestController.signal),
88
+ controller: requestController,
89
+ detach: () => event.signal.removeEventListener('abort', abort),
90
+ };
91
+ event.signal.addEventListener('abort', abort, { once: true });
92
+ preparation = next;
93
+ active = next;
94
+ notify();
95
+ const root = await next.fetched.payload;
96
+ if (event.signal.aborted || requestController.signal.aborted)
97
+ throw requestController.signal.reason ?? event.signal.reason;
98
+ if (!root.redirect)
99
+ break;
100
+ const target = new URL(root.redirect, href);
101
+ if (target.origin !== location.origin || !canCommitInPlace(target.href)) {
102
+ // `controller.redirect` cannot leave the origin, and a file can never be a same-document
103
+ // commit — the browser performs both as a real load instead.
104
+ recovery = target.href;
105
+ throw new Error(`[rshono] cannot soft-navigate to ${target.href}`);
106
+ }
107
+ next.detach();
108
+ next.controller.abort();
109
+ preparation = undefined;
110
+ active = undefined;
111
+ notify();
112
+ href = target.href;
113
+ }
114
+ if (href !== event.destination.url)
115
+ controller.redirect(href);
116
+ // Registered from the precommit, so it runs after the commit in the same list every other
117
+ // navigation handler runs in — the payload follows the runtime's applied-fetch and lost-ping
118
+ // rules rather than arriving before the URL does.
119
+ const prepared = preparation;
120
+ controller.addHandler(() => {
121
+ prepared.detach();
122
+ if (active === prepared) {
123
+ active = undefined;
124
+ notify();
125
+ }
126
+ // The browser's `finished`, `navigatesuccess`, and focus reset must wait for the render too.
127
+ // `apply` settles even when that render fails — the payload's commit is rejected, and its
128
+ // caller's rejection branch asks for a document load — so a payload that can never commit does
129
+ // not leave the navigation open. Only a render React is still suspending stays pending.
130
+ return apply(event, prepared);
131
+ });
132
+ }
133
+ catch (error) {
134
+ const cancelled = event.signal.aborted || preparation?.controller.signal.aborted === true;
135
+ preparation?.detach();
136
+ preparation?.controller.abort();
137
+ if (active === preparation) {
138
+ active = undefined;
139
+ notify();
140
+ }
141
+ if (!cancelled)
142
+ fallbackToDocument(recovery ?? event.destination.url);
143
+ throw error;
144
+ }
145
+ },
146
+ });
147
+ claimed.add(event);
148
+ };
149
+ navigation.addEventListener('navigate', onNavigate);
150
+ return () => {
151
+ navigation.removeEventListener('navigate', onNavigate);
152
+ cancelNavigationPreparation();
153
+ };
154
+ }
155
+ //# sourceMappingURL=navigation-preparation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation-preparation.js","sourceRoot":"","sources":["../../src/runtime/navigation-preparation.ts"],"names":[],"mappings":"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","sourcesContent":["import { useSyncExternalStore } from 'react';\nimport type { RscPayload } from './entry.rsc.js';\n\ntype FetchedPayload = { payload: Promise<RscPayload>; settled: Promise<void> };\n\n/**\n * A flight payload fetched before its navigation committed, plus the wiring the runtime takes over at commit.\n *\n * `controller` is the fetch's own abort switch and `detach` drops the navigation event's abort listener.\n * Both exist because ownership changes hands here: before the commit the event's signal cancels the fetch,\n * and after it the runtime's own rules do — an unapplied fetch stops at once, while one React is already\n * reading waits for a newer commit (see `navigationFetches` in entry.client.tsx).\n */\nexport type PreparedNavigation = {\n href: string;\n fetched: FetchedPayload;\n controller: AbortController;\n detach: () => void;\n};\n\n/**\n * Whether the browser runs `precommitHandler` before committing. `NavigationPrecommitController` is exposed\n * alongside it; a browser that lacks the option ignores the unknown dictionary member, which would leave a\n * `intercept()` with no handler at all — a committed URL and nothing rendered. The caller keeps the ordinary\n * post-commit handler there, which is the behaviour every other browser gets.\n */\nconst canPrecommit = typeof NavigationPrecommitController !== 'undefined';\n\n/** The prepared navigation that has not committed yet; what {@link useNavigationPreparation} observes. */\nlet active: PreparedNavigation | undefined;\nconst subscribers = new Set<() => void>();\nconst notify = () => {\n for (const listener of subscribers) listener();\n};\nconst subscribe = (listener: () => void) => {\n subscribers.add(listener);\n return () => {\n subscribers.delete(listener);\n };\n};\n\n/** Preparation is pending until the URL commits and the runtime takes ownership of its response. */\nexport function useNavigationPreparation(): boolean {\n return useSyncExternalStore(\n subscribe,\n () => active !== undefined,\n () => false,\n );\n}\n\n/** An action or a newer navigation must not commit an older prepared snapshot. */\nexport function cancelNavigationPreparation(): void {\n const preparation = active;\n if (!preparation) return;\n active = undefined;\n preparation.detach();\n preparation.controller.abort();\n notify();\n}\n\n/** The events this listener claimed: their scroll, focus and payload handler belong to the preparation. */\nconst claimed = new WeakSet<NavigateEvent>();\n\n/** Whether the preparation listener claimed this event; `onNavigate` must then register no handler of its own. */\nexport function isPreparedNavigation(event: NavigateEvent): boolean {\n return claimed.has(event);\n}\n\nexport type NavigationPreparationOptions = {\n /** Asks a URL for its flight payload — the same loader the post-commit path uses. */\n fetchPayload: (href: string, signal: AbortSignal) => FetchedPayload;\n /** The events worth preparing; the listener additionally requires a push or replace navigation type. */\n shouldIntercept: (event: NavigateEvent) => boolean;\n /** Whether a URL can be committed in this document; one that names a file cannot. */\n canCommitInPlace: (href: string) => boolean;\n /** Puts a prepared payload on screen; resolves after its render commits. */\n apply: (event: NavigateEvent, prepared: PreparedNavigation) => Promise<void>;\n /** Performs the real load a failed preparation would otherwise leave silent. */\n fallbackToDocument: (href: string) => void;\n};\n\n/**\n * Prepares push and replace navigations before the browser commits them.\n *\n * The payload is fetched from a `precommitHandler`, so the URL, the history entry and the rendered page\n * change together: the ordinary path commits the URL first and can only recover from a failed fetch with a\n * real document load. A payload `redirect()` is resolved here too, which keeps the redirect stub out of\n * history, and the payload reaches the runtime through `controller.addHandler()`, which runs after the\n * commit like any other navigation handler — the same place the post-commit loader would have run.\n *\n * A failure after the event's signal has aborted is supersession, and the navigation that replaced this one\n * owns the screen. Anything else — a refused response, a redirect loop, a redirect this document cannot\n * commit — is a real failure: the caller is asked for a document load rather than aborting into silence.\n */\nexport function listenNavigationPreparation({\n fetchPayload,\n shouldIntercept,\n canCommitInPlace,\n apply,\n fallbackToDocument,\n}: NavigationPreparationOptions): () => void {\n if (!canPrecommit) return () => {};\n\n const onNavigate = (event: NavigateEvent) => {\n if (event.navigationType !== 'push' && event.navigationType !== 'replace') return;\n if (!shouldIntercept(event)) return;\n event.intercept({\n scroll: 'manual',\n focusReset: event.navigationType === 'replace' ? 'manual' : 'after-transition',\n precommitHandler: async (controller: NavigationPrecommitController) => {\n cancelNavigationPreparation();\n let href = event.destination.url;\n const visited = new Set<string>();\n let preparation: PreparedNavigation | undefined;\n let recovery: string | undefined;\n try {\n for (;;) {\n if (visited.has(href) || visited.size >= 10) throw new Error(`[rshono] the payload for ${event.destination.url} redirects in a loop`);\n visited.add(href);\n const requestController = new AbortController();\n const abort = () => {\n requestController.abort(event.signal.reason);\n if (active === preparation) {\n active = undefined;\n notify();\n }\n };\n const next: PreparedNavigation = {\n href,\n fetched: fetchPayload(href, requestController.signal),\n controller: requestController,\n detach: () => event.signal.removeEventListener('abort', abort),\n };\n event.signal.addEventListener('abort', abort, { once: true });\n preparation = next;\n active = next;\n notify();\n const root = await next.fetched.payload;\n if (event.signal.aborted || requestController.signal.aborted) throw requestController.signal.reason ?? event.signal.reason;\n if (!root.redirect) break;\n const target = new URL(root.redirect, href);\n if (target.origin !== location.origin || !canCommitInPlace(target.href)) {\n // `controller.redirect` cannot leave the origin, and a file can never be a same-document\n // commit — the browser performs both as a real load instead.\n recovery = target.href;\n throw new Error(`[rshono] cannot soft-navigate to ${target.href}`);\n }\n next.detach();\n next.controller.abort();\n preparation = undefined;\n active = undefined;\n notify();\n href = target.href;\n }\n if (href !== event.destination.url) controller.redirect(href);\n // Registered from the precommit, so it runs after the commit in the same list every other\n // navigation handler runs in — the payload follows the runtime's applied-fetch and lost-ping\n // rules rather than arriving before the URL does.\n const prepared = preparation;\n controller.addHandler(() => {\n prepared.detach();\n if (active === prepared) {\n active = undefined;\n notify();\n }\n // The browser's `finished`, `navigatesuccess`, and focus reset must wait for the render too.\n // `apply` settles even when that render fails — the payload's commit is rejected, and its\n // caller's rejection branch asks for a document load — so a payload that can never commit does\n // not leave the navigation open. Only a render React is still suspending stays pending.\n return apply(event, prepared);\n });\n } catch (error) {\n const cancelled = event.signal.aborted || preparation?.controller.signal.aborted === true;\n preparation?.detach();\n preparation?.controller.abort();\n if (active === preparation) {\n active = undefined;\n notify();\n }\n if (!cancelled) fallbackToDocument(recovery ?? event.destination.url);\n throw error;\n }\n },\n });\n claimed.add(event);\n };\n navigation.addEventListener('navigate', onNavigate);\n return () => {\n navigation.removeEventListener('navigate', onNavigate);\n cancelNavigationPreparation();\n };\n}\n"]}
@@ -0,0 +1,6 @@
1
+ type NavigationWork = () => void | Promise<void>;
2
+ /** Applies a payload inside the root's Transition, independently of the network request. */
3
+ export declare function startNavigationPayload(update: () => void): void;
4
+ /** Owns network pending and restores the committed payload after traversal abandons a destination. */
5
+ export declare function useNavigationTransition<Payload extends object>(payload: Payload, restorePayload: (payload: Payload) => void): readonly [boolean, (run: NavigationWork) => void];
6
+ export {};
@@ -0,0 +1,62 @@
1
+ import { startTransition as globalTransition, useCallback, useLayoutEffect, useRef, useState, useTransition } from 'react';
2
+ let transitionPayload = globalTransition;
3
+ const documentPart = (href) => {
4
+ const url = new URL(href);
5
+ return url.pathname + url.search;
6
+ };
7
+ /** Applies a payload inside the root's Transition, independently of the network request. */
8
+ export function startNavigationPayload(update) {
9
+ transitionPayload(update);
10
+ }
11
+ /** Owns network pending and restores the committed payload after traversal abandons a destination. */
12
+ export function useNavigationTransition(payload, restorePayload) {
13
+ const [renderPending, startTransition] = useTransition();
14
+ const [networkPending, setNetworkPending] = useState(false);
15
+ const requests = useRef(new Set());
16
+ const committedPayload = useRef(payload);
17
+ const committedDocument = useRef(documentPart(location.href));
18
+ useLayoutEffect(() => {
19
+ transitionPayload = startTransition;
20
+ return () => {
21
+ if (transitionPayload === startTransition)
22
+ transitionPayload = globalTransition;
23
+ };
24
+ }, [startTransition]);
25
+ useLayoutEffect(() => {
26
+ committedPayload.current = payload;
27
+ committedDocument.current = documentPart(location.href);
28
+ }, [payload]);
29
+ useLayoutEffect(() => {
30
+ if (typeof navigation === 'undefined')
31
+ return;
32
+ const abandon = () => {
33
+ requests.current.clear();
34
+ setNetworkPending(false);
35
+ };
36
+ const restore = () => {
37
+ if (documentPart(location.href) === committedDocument.current)
38
+ restorePayload(committedPayload.current);
39
+ };
40
+ navigation.addEventListener('navigate', abandon);
41
+ window.addEventListener('popstate', restore);
42
+ return () => {
43
+ navigation.removeEventListener('navigate', abandon);
44
+ window.removeEventListener('popstate', restore);
45
+ };
46
+ }, [restorePayload]);
47
+ const runNavigation = useCallback((run) => {
48
+ const work = run();
49
+ if (!work)
50
+ return;
51
+ const request = Symbol();
52
+ requests.current.add(request);
53
+ setNetworkPending(true);
54
+ const finish = () => {
55
+ if (requests.current.delete(request))
56
+ setNetworkPending(requests.current.size !== 0);
57
+ };
58
+ void work.then(finish, finish);
59
+ }, []);
60
+ return [renderPending || networkPending, runNavigation];
61
+ }
62
+ //# sourceMappingURL=navigation-transition.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation-transition.js","sourceRoot":"","sources":["../../src/runtime/navigation-transition.ts"],"names":[],"mappings":"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","sourcesContent":["import { startTransition as globalTransition, useCallback, useLayoutEffect, useRef, useState, useTransition } from 'react';\n\ntype NavigationWork = () => void | Promise<void>;\ntype PayloadTransition = (update: () => void) => void;\nlet transitionPayload: PayloadTransition = globalTransition;\nconst documentPart = (href: string): string => {\n const url = new URL(href);\n return url.pathname + url.search;\n};\n\n/** Applies a payload inside the root's Transition, independently of the network request. */\nexport function startNavigationPayload(update: () => void): void {\n transitionPayload(update);\n}\n\n/** Owns network pending and restores the committed payload after traversal abandons a destination. */\nexport function useNavigationTransition<Payload extends object>(\n payload: Payload,\n restorePayload: (payload: Payload) => void,\n): readonly [boolean, (run: NavigationWork) => void] {\n const [renderPending, startTransition] = useTransition();\n const [networkPending, setNetworkPending] = useState(false);\n const requests = useRef(new Set<symbol>());\n const committedPayload = useRef(payload);\n const committedDocument = useRef(documentPart(location.href));\n\n useLayoutEffect(() => {\n transitionPayload = startTransition;\n return () => {\n if (transitionPayload === startTransition) transitionPayload = globalTransition;\n };\n }, [startTransition]);\n useLayoutEffect(() => {\n committedPayload.current = payload;\n committedDocument.current = documentPart(location.href);\n }, [payload]);\n useLayoutEffect(() => {\n if (typeof navigation === 'undefined') return;\n const abandon = () => {\n requests.current.clear();\n setNetworkPending(false);\n };\n const restore = () => {\n if (documentPart(location.href) === committedDocument.current) restorePayload(committedPayload.current);\n };\n navigation.addEventListener('navigate', abandon);\n window.addEventListener('popstate', restore);\n return () => {\n navigation.removeEventListener('navigate', abandon);\n window.removeEventListener('popstate', restore);\n };\n }, [restorePayload]);\n\n const runNavigation = useCallback((run: NavigationWork) => {\n const work = run();\n if (!work) return;\n const request = Symbol();\n requests.current.add(request);\n setNetworkPending(true);\n const finish = () => {\n if (requests.current.delete(request)) setNetworkPending(requests.current.size !== 0);\n };\n void work.then(finish, finish);\n }, []);\n\n return [renderPending || networkPending, runNavigation];\n}\n"]}
@@ -1,11 +1,23 @@
1
1
  import { type ReactNode } from 'react';
2
+ /** Outcome of a router action: a committed page, an unavailable history entry, or a browser load. */
3
+ export type NavigationOutcome = {
4
+ kind: 'rendered';
5
+ url: URL;
6
+ } | {
7
+ kind: 'no-op';
8
+ } | {
9
+ kind: 'document-navigation';
10
+ };
2
11
  /**
3
12
  * Imperative navigation actions, reached as `useNavigation().router`.
4
13
  *
5
- * Every action is a **soft** navigation: the page's flight payload is fetched and applied in place, so
14
+ * When intercepted, actions fetch and apply the page's flight payload in place, so
6
15
  * client component state outside the changed subtree survives. Off-site hrefs — and a traversal that leaves
7
16
  * the app — fall back to a full load.
8
17
  *
18
+ * Each action resolves with a {@link NavigationOutcome}; `rendered` waits for the destination's DOM
19
+ * commit. Failed or superseded intercepted navigations reject, with `AbortError` on supersession.
20
+ *
9
21
  * Soft navigation is the browser's
10
22
  * {@link https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API | Navigation API}; where that is
11
23
  * missing, every action below is still correct and simply performs a real browser load.
@@ -22,29 +34,26 @@ import { type ReactNode } from 'react';
22
34
  */
23
35
  export interface NavigationRouter {
24
36
  /** Navigates to `href` and pushes a new history entry. */
25
- push(href: string): void;
37
+ push(href: string): Promise<NavigationOutcome>;
26
38
  /** Navigates to `href`, replacing the current history entry instead of adding one. */
27
- replace(href: string): void;
39
+ replace(href: string): Promise<NavigationOutcome>;
28
40
  /** Steps one entry back in the browser's session history. Nothing to go back to is a no-op. */
29
- back(): void;
41
+ back(): Promise<NavigationOutcome>;
30
42
  /** Steps one entry forward in the browser's session history. A no-op on the newest entry. */
31
- forward(): void;
43
+ forward(): Promise<NavigationOutcome>;
32
44
  /** Re-fetches the current route from the server, re-running its server components. */
33
- refresh(): void;
45
+ refresh(): Promise<NavigationOutcome>;
34
46
  /** `true` while a soft navigation is in flight — use it to disable controls or show a spinner. */
35
47
  pending: boolean;
36
48
  }
37
49
  /** The current location plus the {@link NavigationRouter}, as returned by {@link useNavigation}. */
38
50
  export interface NavigationState {
39
51
  /**
40
- * The full current {@link URL}. A fresh instance per navigation, so mutating it affects nothing else
52
+ * The full current {@link URL}. This hook returns its own instance, so mutating it affects nothing else
41
53
  * — it is not written back to the address bar.
42
54
  *
43
- * The path, query and origin travel in the page payload, but the fragment cannot: a browser leaves `#…`
44
- * out of the request line, so the server renders every page without one. `url.hash` is therefore read
45
- * from the browser after hydration and follows `hashchange` — an in-page link, Back/Forward between
46
- * anchors of one document, or opening the document at one. Nothing else about the URL is affected; see
47
- * {@link useNavigation} for the `render: 'static'` case.
55
+ * During server rendering and hydration, the URL comes from the page payload. After hydration it
56
+ * follows the browser address, including query and fragment changes, independently of page fetching.
48
57
  */
49
58
  url: URL;
50
59
  /** Matched route params for the current page, e.g. `{ id: '42' }` for `/profile/:id`. */
@@ -53,7 +62,7 @@ export interface NavigationState {
53
62
  router: NavigationRouter;
54
63
  }
55
64
  /**
56
- * Carries the live {@link NavigationRouter} from the hydration runtime down to {@link RouterProvider}.
65
+ * Carries the live {@link NavigationRouter} from the hydration runtime to {@link useNavigation}.
57
66
  *
58
67
  * @internal
59
68
  */
@@ -81,20 +90,13 @@ export declare function useNavigationPathname(): string | undefined;
81
90
  * Reactive access to the current URL and programmatic navigation, in one hook. Call it from a
82
91
  * `'use client'` component.
83
92
  *
84
- * `url` and `params` are computed on the server and travel in the flight payload, so they are correct
85
- * during SSR — no hydration flicker — and update on every navigation. `router` holds the imperative
86
- * actions plus a `pending` flag, `true` while a soft navigation is in flight.
87
- *
88
- * The fragment is the exception the payload cannot cover: a browser never sends `#…` to the server, so
89
- * `url.hash` is read from the address bar after hydration and kept in sync on `hashchange` — an in-page
90
- * link, Back/Forward between anchors, or opening the document at `#section` — with no request either way.
91
- * Everything before the `#` remains what the payload carried.
93
+ * During SSR and hydration, `url` and `params` come from the server payload. After hydration `url`
94
+ * follows the browser address, including on prerendered pages and while the next payload is pending.
95
+ * `params` still describes the displayed page until its replacement commits. `router` holds the
96
+ * imperative actions and a `pending` flag, true while a soft navigation is in flight.
92
97
  *
93
- * **On a `render: 'static'` route `url` is frozen at build time**, origin included and query empty. The
94
- * payload is one prerendered set of bytes and this reads the `href` in it, so it is the page's own
95
- * `PageProps.url` — the same value, not a live one, `url.hash` aside. A page whose output depends on the
96
- * query wants `render: 'dynamic'`; a component that only needs it after hydration can read
97
- * `location.search` in an effect.
98
+ * A static server-rendered page still cannot depend on a request's query. Client components can read
99
+ * the current query directly through this hook after hydration; no additional effect is required.
98
100
  *
99
101
  * Hooks can't run in a server component; read the same data there from `getRequestContext()`.
100
102
  *
@@ -1,36 +1,34 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { createContext, useContext, useMemo, useSyncExternalStore } from 'react';
4
- const noop = () => { };
3
+ import { createContext, startTransition, useContext, useLayoutEffect, useMemo, useState, useSyncExternalStore } from 'react';
4
+ const noop = () => Promise.resolve({ kind: 'no-op' });
5
5
  const defaultRouter = { push: noop, replace: noop, back: noop, forward: noop, refresh: noop, pending: false };
6
6
  /**
7
- * Carries the live {@link NavigationRouter} from the hydration runtime down to {@link RouterProvider}.
7
+ * Carries the live {@link NavigationRouter} from the hydration runtime to {@link useNavigation}.
8
8
  *
9
9
  * @internal
10
10
  */
11
11
  export const RouterContext = createContext(defaultRouter);
12
12
  const NavigationContext = createContext(null);
13
- /**
14
- * The browser's fragment navigation: the one part of the address a payload can never carry, and the one part
15
- * that can move without a page data request. `hashchange` is every way it moves within a document — an
16
- * in-page link, Back/Forward between anchors of one page, opening the document at `#section` covered by the
17
- * post-hydration check `useSyncExternalStore` makes for a changed snapshot. A cross-page `#anchor` commits a
18
- * payload instead; the re-render that follows reads the fragment then.
19
- */
20
- function subscribeToHash(onStoreChange) {
13
+ function subscribeToBrowserUrl(onStoreChange) {
14
+ if (typeof navigation !== 'undefined')
15
+ navigation.addEventListener('currententrychange', onStoreChange);
21
16
  window.addEventListener('hashchange', onStoreChange);
22
- return () => window.removeEventListener('hashchange', onStoreChange);
17
+ window.addEventListener('popstate', onStoreChange);
18
+ return () => {
19
+ if (typeof navigation !== 'undefined')
20
+ navigation.removeEventListener('currententrychange', onStoreChange);
21
+ window.removeEventListener('hashchange', onStoreChange);
22
+ window.removeEventListener('popstate', onStoreChange);
23
+ };
23
24
  }
24
- /** The live fragment, `#…` included, or `''`. */
25
- const readHash = () => window.location.hash;
26
- /**
27
- * What the fragment reads as while the server snapshot is in use — server render and hydration. The server
28
- * never saw one, so the payload's URL has none; answering with the live fragment here would render markup
29
- * the server did not and fail hydration. The empty string is exactly what the payload carries, and the
30
- * post-hydration re-render that `useSyncExternalStore` performs for a changed snapshot is where the
31
- * browser's own arrives.
32
- */
33
- const readServerHash = () => '';
25
+ const readBrowserUrl = () => (typeof navigation !== 'undefined' ? navigation.currentEntry?.url : undefined) ?? location.href;
26
+ // This immutable snapshot distinguishes hydration from a later client mount. It does not subscribe
27
+ // to URL mutations: those updates remain transition-owned. A newly mounted error-boundary child must
28
+ // not start from an old payload URL and throw before its layout effect can reconcile the address.
29
+ const subscribeToHydration = () => () => { };
30
+ const readHydrated = () => true;
31
+ const readServerHydrated = () => false;
34
32
  /**
35
33
  * Publishes the per-render location and params for {@link useNavigation} to read. The RSC entry wraps
36
34
  * every page in one.
@@ -38,16 +36,8 @@ const readServerHash = () => '';
38
36
  * @internal
39
37
  */
40
38
  export function RouterProvider({ href, params, children }) {
41
- const router = useContext(RouterContext);
42
- // `href` is the payload's URL — see `subscribeToHash` — so the browser's fragment is applied on top of it.
43
- // This is the only client-side part of `url`; path, query and origin stay exactly what the payload said.
44
- const hash = useSyncExternalStore(subscribeToHash, readHash, readServerHash);
45
- const value = useMemo(() => {
46
- const url = new URL(href);
47
- url.hash = hash;
48
- return { url, params, router };
49
- }, [href, hash, params, router]);
50
- return _jsx(NavigationContext.Provider, { value: value, children: children });
39
+ const route = useMemo(() => ({ href, params }), [href, params]);
40
+ return _jsx(NavigationContext.Provider, { value: route, children: children });
51
41
  }
52
42
  /**
53
43
  * The pathname of the payload on screen, for framework components that key themselves to the route.
@@ -57,26 +47,20 @@ export function RouterProvider({ href, params, children }) {
57
47
  * @internal
58
48
  */
59
49
  export function useNavigationPathname() {
60
- return useContext(NavigationContext)?.url.pathname;
50
+ const route = useContext(NavigationContext);
51
+ return route ? new URL(route.href).pathname : undefined;
61
52
  }
62
53
  /**
63
54
  * Reactive access to the current URL and programmatic navigation, in one hook. Call it from a
64
55
  * `'use client'` component.
65
56
  *
66
- * `url` and `params` are computed on the server and travel in the flight payload, so they are correct
67
- * during SSR — no hydration flicker — and update on every navigation. `router` holds the imperative
68
- * actions plus a `pending` flag, `true` while a soft navigation is in flight.
69
- *
70
- * The fragment is the exception the payload cannot cover: a browser never sends `#…` to the server, so
71
- * `url.hash` is read from the address bar after hydration and kept in sync on `hashchange` — an in-page
72
- * link, Back/Forward between anchors, or opening the document at `#section` — with no request either way.
73
- * Everything before the `#` remains what the payload carried.
57
+ * During SSR and hydration, `url` and `params` come from the server payload. After hydration `url`
58
+ * follows the browser address, including on prerendered pages and while the next payload is pending.
59
+ * `params` still describes the displayed page until its replacement commits. `router` holds the
60
+ * imperative actions and a `pending` flag, true while a soft navigation is in flight.
74
61
  *
75
- * **On a `render: 'static'` route `url` is frozen at build time**, origin included and query empty. The
76
- * payload is one prerendered set of bytes and this reads the `href` in it, so it is the page's own
77
- * `PageProps.url` — the same value, not a live one, `url.hash` aside. A page whose output depends on the
78
- * query wants `render: 'dynamic'`; a component that only needs it after hydration can read
79
- * `location.search` in an effect.
62
+ * A static server-rendered page still cannot depend on a request's query. Client components can read
63
+ * the current query directly through this hook after hydration; no additional effect is required.
80
64
  *
81
65
  * Hooks can't run in a server component; read the same data there from `getRequestContext()`.
82
66
  *
@@ -109,6 +93,25 @@ export function useNavigation() {
109
93
  if (!value) {
110
94
  throw new Error("[rshono] useNavigation() must be called inside a 'use client' component rendered by a page. In a server component, read the URL from getRequestContext() instead.");
111
95
  }
112
- return value;
96
+ const router = useContext(RouterContext);
97
+ // Preserve SSR markup, then read the address independently of page fetching after hydration.
98
+ const hydrated = useSyncExternalStore(subscribeToHydration, readHydrated, readServerHydrated);
99
+ const [browserHref, setBrowserHref] = useState(() => (hydrated ? readBrowserUrl() : value.href));
100
+ useLayoutEffect(() => {
101
+ let lastHref;
102
+ const update = () => {
103
+ const href = readBrowserUrl();
104
+ // Several browser events may report the same address; avoid restarting an unchanged transition.
105
+ if (href === lastHref)
106
+ return;
107
+ lastHref = href;
108
+ startTransition(() => setBrowserHref(href));
109
+ };
110
+ const unsubscribe = subscribeToBrowserUrl(update);
111
+ // Catch address changes between rendering and registration, including initial fragments.
112
+ update();
113
+ return unsubscribe;
114
+ }, []);
115
+ return useMemo(() => ({ url: new URL(browserHref), params: value.params, router }), [browserHref, value.params, router]);
113
116
  }
114
117
  //# sourceMappingURL=navigation.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"navigation.js","sourceRoot":"","sources":["../../src/runtime/navigation.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAEb,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,OAAO,EAAE,oBAAoB,EAAkB,MAAM,OAAO,CAAC;AAyDjG,MAAM,IAAI,GAAG,GAAG,EAAE,GAAE,CAAC,CAAC;AAEtB,MAAM,aAAa,GAAqB,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AAEhI;;;;GAIG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,aAAa,CAAmB,aAAa,CAAC,CAAC;AAE5E,MAAM,iBAAiB,GAAG,aAAa,CAAyB,IAAI,CAAC,CAAC;AAEtE;;;;;;GAMG;AACH,SAAS,eAAe,CAAC,aAAyB;IAChD,MAAM,CAAC,gBAAgB,CAAC,YAAY,EAAE,aAAa,CAAC,CAAC;IACrD,OAAO,GAAG,EAAE,CAAC,MAAM,CAAC,mBAAmB,CAAC,YAAY,EAAE,aAAa,CAAC,CAAC;AACvE,CAAC;AAED,iDAAiD;AACjD,MAAM,QAAQ,GAAG,GAAW,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;AAEpD;;;;;;GAMG;AACH,MAAM,cAAc,GAAG,GAAW,EAAE,CAAC,EAAE,CAAC;AAExC;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAyE;IAC9H,MAAM,MAAM,GAAG,UAAU,CAAC,aAAa,CAAC,CAAC;IACzC,2GAA2G;IAC3G,yGAAyG;IACzG,MAAM,IAAI,GAAG,oBAAoB,CAAC,eAAe,EAAE,QAAQ,EAAE,cAAc,CAAC,CAAC;IAC7E,MAAM,KAAK,GAAG,OAAO,CAAkB,GAAG,EAAE;QAC1C,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,CAAC;QAC1B,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;QAChB,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjC,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAEjC,OAAO,KAAC,iBAAiB,CAAC,QAAQ,IAAC,KAAK,EAAE,KAAK,YAAG,QAAQ,GAA8B,CAAC;AAC3F,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB;IACnC,OAAO,UAAU,CAAC,iBAAiB,CAAC,EAAE,GAAG,CAAC,QAAQ,CAAC;AACrD,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,KAAK,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC;IAC5C,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CACb,mKAAmK,CACpK,CAAC;IACJ,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC","sourcesContent":["'use client';\n\nimport { createContext, useContext, useMemo, useSyncExternalStore, type ReactNode } from 'react';\n\n/**\n * Imperative navigation actions, reached as `useNavigation().router`.\n *\n * Every action is a **soft** navigation: the page's flight payload is fetched and applied in place, so\n * client component state outside the changed subtree survives. Off-site hrefs — and a traversal that leaves\n * the app — fall back to a full load.\n *\n * Soft navigation is the browser's\n * {@link https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API | Navigation API}; where that is\n * missing, every action below is still correct and simply performs a real browser load.\n *\n * @example\n * ```tsx\n * const { router } = useNavigation();\n * router.push('/dashboard'); // navigate, new history entry\n * router.replace('/login'); // navigate, no new entry\n * router.back(); // one entry back, as the browser's button does\n * router.forward(); // one entry forward\n * router.refresh(); // re-run this route's server components\n * ```\n */\nexport interface NavigationRouter {\n /** Navigates to `href` and pushes a new history entry. */\n push(href: string): void;\n /** Navigates to `href`, replacing the current history entry instead of adding one. */\n replace(href: string): void;\n /** Steps one entry back in the browser's session history. Nothing to go back to is a no-op. */\n back(): void;\n /** Steps one entry forward in the browser's session history. A no-op on the newest entry. */\n forward(): void;\n /** Re-fetches the current route from the server, re-running its server components. */\n refresh(): void;\n /** `true` while a soft navigation is in flight — use it to disable controls or show a spinner. */\n pending: boolean;\n}\n\n/** The current location plus the {@link NavigationRouter}, as returned by {@link useNavigation}. */\nexport interface NavigationState {\n /**\n * The full current {@link URL}. A fresh instance per navigation, so mutating it affects nothing else\n * — it is not written back to the address bar.\n *\n * The path, query and origin travel in the page payload, but the fragment cannot: a browser leaves `#…`\n * out of the request line, so the server renders every page without one. `url.hash` is therefore read\n * from the browser after hydration and follows `hashchange` — an in-page link, Back/Forward between\n * anchors of one document, or opening the document at one. Nothing else about the URL is affected; see\n * {@link useNavigation} for the `render: 'static'` case.\n */\n url: URL;\n /** Matched route params for the current page, e.g. `{ id: '42' }` for `/profile/:id`. */\n params: Record<string, string>;\n /** Imperative navigation actions and the `pending` flag. */\n router: NavigationRouter;\n}\n\nconst noop = () => {};\n\nconst defaultRouter: NavigationRouter = { push: noop, replace: noop, back: noop, forward: noop, refresh: noop, pending: false };\n\n/**\n * Carries the live {@link NavigationRouter} from the hydration runtime down to {@link RouterProvider}.\n *\n * @internal\n */\nexport const RouterContext = createContext<NavigationRouter>(defaultRouter);\n\nconst NavigationContext = createContext<NavigationState | null>(null);\n\n/**\n * The browser's fragment navigation: the one part of the address a payload can never carry, and the one part\n * that can move without a page data request. `hashchange` is every way it moves within a document — an\n * in-page link, Back/Forward between anchors of one page, opening the document at `#section` covered by the\n * post-hydration check `useSyncExternalStore` makes for a changed snapshot. A cross-page `#anchor` commits a\n * payload instead; the re-render that follows reads the fragment then.\n */\nfunction subscribeToHash(onStoreChange: () => void): () => void {\n window.addEventListener('hashchange', onStoreChange);\n return () => window.removeEventListener('hashchange', onStoreChange);\n}\n\n/** The live fragment, `#…` included, or `''`. */\nconst readHash = (): string => window.location.hash;\n\n/**\n * What the fragment reads as while the server snapshot is in use — server render and hydration. The server\n * never saw one, so the payload's URL has none; answering with the live fragment here would render markup\n * the server did not and fail hydration. The empty string is exactly what the payload carries, and the\n * post-hydration re-render that `useSyncExternalStore` performs for a changed snapshot is where the\n * browser's own arrives.\n */\nconst readServerHash = (): string => '';\n\n/**\n * Publishes the per-render location and params for {@link useNavigation} to read. The RSC entry wraps\n * every page in one.\n *\n * @internal\n */\nexport function RouterProvider({ href, params, children }: { href: string; params: Record<string, string>; children: ReactNode }) {\n const router = useContext(RouterContext);\n // `href` is the payload's URL — see `subscribeToHash` — so the browser's fragment is applied on top of it.\n // This is the only client-side part of `url`; path, query and origin stay exactly what the payload said.\n const hash = useSyncExternalStore(subscribeToHash, readHash, readServerHash);\n const value = useMemo<NavigationState>(() => {\n const url = new URL(href);\n url.hash = hash;\n return { url, params, router };\n }, [href, hash, params, router]);\n\n return <NavigationContext.Provider value={value}>{children}</NavigationContext.Provider>;\n}\n\n/**\n * The pathname of the payload on screen, for framework components that key themselves to the route.\n * Unlike {@link useNavigation} it tolerates being rendered outside a page: with no navigation context to\n * read it answers `undefined`, so the caller can treat the absent route as \"no key\".\n *\n * @internal\n */\nexport function useNavigationPathname(): string | undefined {\n return useContext(NavigationContext)?.url.pathname;\n}\n\n/**\n * Reactive access to the current URL and programmatic navigation, in one hook. Call it from a\n * `'use client'` component.\n *\n * `url` and `params` are computed on the server and travel in the flight payload, so they are correct\n * during SSR — no hydration flicker — and update on every navigation. `router` holds the imperative\n * actions plus a `pending` flag, `true` while a soft navigation is in flight.\n *\n * The fragment is the exception the payload cannot cover: a browser never sends `#…` to the server, so\n * `url.hash` is read from the address bar after hydration and kept in sync on `hashchange` — an in-page\n * link, Back/Forward between anchors, or opening the document at `#section` — with no request either way.\n * Everything before the `#` remains what the payload carried.\n *\n * **On a `render: 'static'` route `url` is frozen at build time**, origin included and query empty. The\n * payload is one prerendered set of bytes and this reads the `href` in it, so it is the page's own\n * `PageProps.url` — the same value, not a live one, `url.hash` aside. A page whose output depends on the\n * query wants `render: 'dynamic'`; a component that only needs it after hydration can read\n * `location.search` in an effect.\n *\n * Hooks can't run in a server component; read the same data there from `getRequestContext()`.\n *\n * @example\n * ```tsx\n * 'use client';\n * import { useNavigation } from '@rshono/core/client';\n *\n * export function NextPage() {\n * const { url, router } = useNavigation();\n * const page = Number(url.searchParams.get('page') ?? '1');\n * return (\n * <button disabled={router.pending} onClick={() => router.push(`${url.pathname}?page=${page + 1}`)}>\n * Next {router.pending ? '…' : ''}\n * </button>\n * );\n * }\n * ```\n *\n * @returns The current {@link NavigationState}: `url` and `params`, plus `router`\n * ({@link NavigationRouter}) with `push` / `replace` / `back` / `forward` / `refresh` / `pending`.\n * @throws If called outside a page's React tree, where there is no navigation\n * context to read.\n *\n * @see {@link https://www.rshono.com/docs/api#rshonocoreclient | Docs — `@rshono/core/client`}\n * @see {@link https://www.rshono.com/docs/pages#client-components | Docs — client components}\n */\nexport function useNavigation(): NavigationState {\n const value = useContext(NavigationContext);\n if (!value) {\n throw new Error(\n \"[rshono] useNavigation() must be called inside a 'use client' component rendered by a page. In a server component, read the URL from getRequestContext() instead.\",\n );\n }\n return value;\n}\n"]}
1
+ {"version":3,"file":"navigation.js","sourceRoot":"","sources":["../../src/runtime/navigation.tsx"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\nimport { createContext, startTransition, useContext, useLayoutEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react';\n\n/** Outcome of a router action: a committed page, an unavailable history entry, or a browser load. */\nexport type NavigationOutcome = { kind: 'rendered'; url: URL } | { kind: 'no-op' } | { kind: 'document-navigation' };\n\n/**\n * Imperative navigation actions, reached as `useNavigation().router`.\n *\n * When intercepted, actions fetch and apply the page's flight payload in place, so\n * client component state outside the changed subtree survives. Off-site hrefs — and a traversal that leaves\n * the app — fall back to a full load.\n *\n * Each action resolves with a {@link NavigationOutcome}; `rendered` waits for the destination's DOM\n * commit. Failed or superseded intercepted navigations reject, with `AbortError` on supersession.\n *\n * Soft navigation is the browser's\n * {@link https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API | Navigation API}; where that is\n * missing, every action below is still correct and simply performs a real browser load.\n *\n * @example\n * ```tsx\n * const { router } = useNavigation();\n * router.push('/dashboard'); // navigate, new history entry\n * router.replace('/login'); // navigate, no new entry\n * router.back(); // one entry back, as the browser's button does\n * router.forward(); // one entry forward\n * router.refresh(); // re-run this route's server components\n * ```\n */\nexport interface NavigationRouter {\n /** Navigates to `href` and pushes a new history entry. */\n push(href: string): Promise<NavigationOutcome>;\n /** Navigates to `href`, replacing the current history entry instead of adding one. */\n replace(href: string): Promise<NavigationOutcome>;\n /** Steps one entry back in the browser's session history. Nothing to go back to is a no-op. */\n back(): Promise<NavigationOutcome>;\n /** Steps one entry forward in the browser's session history. A no-op on the newest entry. */\n forward(): Promise<NavigationOutcome>;\n /** Re-fetches the current route from the server, re-running its server components. */\n refresh(): Promise<NavigationOutcome>;\n /** `true` while a soft navigation is in flight — use it to disable controls or show a spinner. */\n pending: boolean;\n}\n\n/** The current location plus the {@link NavigationRouter}, as returned by {@link useNavigation}. */\nexport interface NavigationState {\n /**\n * The full current {@link URL}. This hook returns its own instance, so mutating it affects nothing else\n * — it is not written back to the address bar.\n *\n * During server rendering and hydration, the URL comes from the page payload. After hydration it\n * follows the browser address, including query and fragment changes, independently of page fetching.\n */\n url: URL;\n /** Matched route params for the current page, e.g. `{ id: '42' }` for `/profile/:id`. */\n params: Record<string, string>;\n /** Imperative navigation actions and the `pending` flag. */\n router: NavigationRouter;\n}\n\nconst noop = (): Promise<NavigationOutcome> => Promise.resolve({ kind: 'no-op' });\n\nconst defaultRouter: NavigationRouter = { push: noop, replace: noop, back: noop, forward: noop, refresh: noop, pending: false };\n\n/**\n * Carries the live {@link NavigationRouter} from the hydration runtime to {@link useNavigation}.\n *\n * @internal\n */\nexport const RouterContext = createContext<NavigationRouter>(defaultRouter);\n\n// Only server-rendered page identity belongs in this provider. Live browser state is composed in\n// the public hook, so an address change does not replace the context for the whole payload tree.\ntype RouteState = { href: string; params: Record<string, string> };\nconst NavigationContext = createContext<RouteState | null>(null);\n\nfunction subscribeToBrowserUrl(onStoreChange: () => void): () => void {\n if (typeof navigation !== 'undefined') navigation.addEventListener('currententrychange', onStoreChange);\n window.addEventListener('hashchange', onStoreChange);\n window.addEventListener('popstate', onStoreChange);\n return () => {\n if (typeof navigation !== 'undefined') navigation.removeEventListener('currententrychange', onStoreChange);\n window.removeEventListener('hashchange', onStoreChange);\n window.removeEventListener('popstate', onStoreChange);\n };\n}\n\nconst readBrowserUrl = (): string => (typeof navigation !== 'undefined' ? navigation.currentEntry?.url : undefined) ?? location.href;\n\n// This immutable snapshot distinguishes hydration from a later client mount. It does not subscribe\n// to URL mutations: those updates remain transition-owned. A newly mounted error-boundary child must\n// not start from an old payload URL and throw before its layout effect can reconcile the address.\nconst subscribeToHydration = () => () => {};\nconst readHydrated = () => true;\nconst readServerHydrated = () => false;\n\n/**\n * Publishes the per-render location and params for {@link useNavigation} to read. The RSC entry wraps\n * every page in one.\n *\n * @internal\n */\nexport function RouterProvider({ href, params, children }: { href: string; params: Record<string, string>; children: ReactNode }) {\n const route = useMemo(() => ({ href, params }), [href, params]);\n return <NavigationContext.Provider value={route}>{children}</NavigationContext.Provider>;\n}\n\n/**\n * The pathname of the payload on screen, for framework components that key themselves to the route.\n * Unlike {@link useNavigation} it tolerates being rendered outside a page: with no navigation context to\n * read it answers `undefined`, so the caller can treat the absent route as \"no key\".\n *\n * @internal\n */\nexport function useNavigationPathname(): string | undefined {\n const route = useContext(NavigationContext);\n return route ? new URL(route.href).pathname : undefined;\n}\n\n/**\n * Reactive access to the current URL and programmatic navigation, in one hook. Call it from a\n * `'use client'` component.\n *\n * During SSR and hydration, `url` and `params` come from the server payload. After hydration `url`\n * follows the browser address, including on prerendered pages and while the next payload is pending.\n * `params` still describes the displayed page until its replacement commits. `router` holds the\n * imperative actions and a `pending` flag, true while a soft navigation is in flight.\n *\n * A static server-rendered page still cannot depend on a request's query. Client components can read\n * the current query directly through this hook after hydration; no additional effect is required.\n *\n * Hooks can't run in a server component; read the same data there from `getRequestContext()`.\n *\n * @example\n * ```tsx\n * 'use client';\n * import { useNavigation } from '@rshono/core/client';\n *\n * export function NextPage() {\n * const { url, router } = useNavigation();\n * const page = Number(url.searchParams.get('page') ?? '1');\n * return (\n * <button disabled={router.pending} onClick={() => router.push(`${url.pathname}?page=${page + 1}`)}>\n * Next {router.pending ? '…' : ''}\n * </button>\n * );\n * }\n * ```\n *\n * @returns The current {@link NavigationState}: `url` and `params`, plus `router`\n * ({@link NavigationRouter}) with `push` / `replace` / `back` / `forward` / `refresh` / `pending`.\n * @throws If called outside a page's React tree, where there is no navigation\n * context to read.\n *\n * @see {@link https://www.rshono.com/docs/api#rshonocoreclient | Docs — `@rshono/core/client`}\n * @see {@link https://www.rshono.com/docs/pages#client-components | Docs — client components}\n */\nexport function useNavigation(): NavigationState {\n const value = useContext(NavigationContext);\n if (!value) {\n throw new Error(\n \"[rshono] useNavigation() must be called inside a 'use client' component rendered by a page. In a server component, read the URL from getRequestContext() instead.\",\n );\n }\n const router = useContext(RouterContext);\n // Preserve SSR markup, then read the address independently of page fetching after hydration.\n const hydrated = useSyncExternalStore(subscribeToHydration, readHydrated, readServerHydrated);\n const [browserHref, setBrowserHref] = useState(() => (hydrated ? readBrowserUrl() : value.href));\n useLayoutEffect(() => {\n let lastHref: string | undefined;\n const update = () => {\n const href = readBrowserUrl();\n // Several browser events may report the same address; avoid restarting an unchanged transition.\n if (href === lastHref) return;\n lastHref = href;\n startTransition(() => setBrowserHref(href));\n };\n const unsubscribe = subscribeToBrowserUrl(update);\n // Catch address changes between rendering and registration, including initial fragments.\n update();\n return unsubscribe;\n }, []);\n return useMemo(() => ({ url: new URL(browserHref), params: value.params, router }), [browserHref, value.params, router]);\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rshono/core",
3
- "version": "1.0.0-rc.28",
3
+ "version": "1.0.0-rc.29",
4
4
  "description": "Minimalist web framework — Hono + Rspack + React Server Components",
5
5
  "author": "Lasse <lasse@lassetange.com> (https://www.lassetange.com)",
6
6
  "license": "MIT",