@djangocfg/ui-core 2.1.292 → 2.1.294

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,154 @@
1
+ /**
2
+ * Query string parsers — typed marshalling between URL strings and JS values.
3
+ *
4
+ * WHY:
5
+ * `URLSearchParams.get('page')` is always a string. Coercing it
6
+ * inside every component (`Number(...) || 1`) is repetitive and
7
+ * error-prone. A parser bakes in `parse(string) → T | null` and
8
+ * `serialize(T) → string`, plus optional equality (so we can clear
9
+ * default values from the URL — known as `clearOnDefault`).
10
+ *
11
+ * Parsers are zero-runtime objects — pure functions in plain
12
+ * structures. No deps, no validation libraries. If a consumer wants
13
+ * zod / standard-schema, they wrap `parseAsJson` themselves.
14
+ *
15
+ * @example
16
+ * import { useQueryState, parseAsInteger, parseAsString } from '@djangocfg/ui-core/hooks';
17
+ * const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
18
+ * const [q, setQ] = useQueryState('q', parseAsString);
19
+ */
20
+
21
+ /** Convert a URL string value into a typed value, or `null` on parse failure. */
22
+ export interface QueryParser<T> {
23
+ parse: (value: string) => T | null;
24
+ serialize: (value: T) => string;
25
+ /** Equality check, used by `clearOnDefault` to strip default values from the URL. */
26
+ eq: (a: T, b: T) => boolean;
27
+ /** Optional default. When set, missing key returns it instead of `null`. */
28
+ defaultValue?: T;
29
+ }
30
+
31
+ /** Builder — adds `withDefault` chain on top of a parser. */
32
+ export interface QueryParserBuilder<T> extends QueryParser<T> {
33
+ withDefault(value: T): QueryParser<T> & { defaultValue: T };
34
+ }
35
+
36
+ function builder<T>(parser: Omit<QueryParser<T>, 'eq'> & { eq?: QueryParser<T>['eq'] }): QueryParserBuilder<T> {
37
+ const eq = parser.eq ?? ((a, b) => a === b);
38
+ const base: QueryParser<T> = { ...parser, eq };
39
+ return {
40
+ ...base,
41
+ withDefault(defaultValue: T) {
42
+ return { ...base, defaultValue };
43
+ },
44
+ };
45
+ }
46
+
47
+ // ────────────────────────────────────────────────────────────────────
48
+ // Built-in parsers
49
+ // ────────────────────────────────────────────────────────────────────
50
+
51
+ export const parseAsString: QueryParserBuilder<string> = builder({
52
+ parse: (v) => v,
53
+ serialize: (v) => v,
54
+ });
55
+
56
+ export const parseAsInteger: QueryParserBuilder<number> = builder({
57
+ parse: (v) => {
58
+ const n = Number.parseInt(v, 10);
59
+ return Number.isFinite(n) ? n : null;
60
+ },
61
+ serialize: (v) => String(v),
62
+ });
63
+
64
+ export const parseAsFloat: QueryParserBuilder<number> = builder({
65
+ parse: (v) => {
66
+ const n = Number.parseFloat(v);
67
+ return Number.isFinite(n) ? n : null;
68
+ },
69
+ serialize: (v) => String(v),
70
+ });
71
+
72
+ export const parseAsBoolean: QueryParserBuilder<boolean> = builder({
73
+ // `?debug` (no value) → true, `?debug=true` → true, `?debug=false` → false.
74
+ parse: (v) => v === '' || v === 'true' || v === '1',
75
+ serialize: (v) => (v ? 'true' : 'false'),
76
+ });
77
+
78
+ export const parseAsIsoDate: QueryParserBuilder<Date> = builder({
79
+ parse: (v) => {
80
+ const ms = Date.parse(v);
81
+ return Number.isFinite(ms) ? new Date(ms) : null;
82
+ },
83
+ serialize: (v) => v.toISOString(),
84
+ eq: (a, b) => a.getTime() === b.getTime(),
85
+ });
86
+
87
+ /**
88
+ * `parseAsStringEnum(['asc','desc'])` — restricts values to a fixed list.
89
+ * Returns `null` on anything outside the set, so `withDefault` falls back.
90
+ */
91
+ export function parseAsStringEnum<T extends string>(
92
+ values: readonly T[]
93
+ ): QueryParserBuilder<T> {
94
+ const set = new Set<string>(values);
95
+ return builder({
96
+ parse: (v) => (set.has(v) ? (v as T) : null),
97
+ serialize: (v) => v,
98
+ });
99
+ }
100
+
101
+ /**
102
+ * `parseAsArrayOf(parseAsInteger)` — comma-separated list of typed values.
103
+ * Empty array serializes to empty string (which `useQueryState` strips).
104
+ */
105
+ export function parseAsArrayOf<T>(
106
+ itemParser: QueryParser<T>,
107
+ separator: string = ','
108
+ ): QueryParserBuilder<T[]> {
109
+ return builder({
110
+ parse: (v) => {
111
+ if (v === '') return [];
112
+ const parts = v.split(separator);
113
+ const out: T[] = [];
114
+ for (const part of parts) {
115
+ const parsed = itemParser.parse(part);
116
+ if (parsed === null) return null;
117
+ out.push(parsed);
118
+ }
119
+ return out;
120
+ },
121
+ serialize: (arr) => arr.map(itemParser.serialize).join(separator),
122
+ eq: (a, b) => {
123
+ if (a.length !== b.length) return false;
124
+ for (let i = 0; i < a.length; i++) {
125
+ if (!itemParser.eq(a[i] as T, b[i] as T)) return false;
126
+ }
127
+ return true;
128
+ },
129
+ });
130
+ }
131
+
132
+ /**
133
+ * `parseAsJson<{ a: number }>()` — JSON-encode complex shapes.
134
+ * Pass a guard for runtime validation; without one any JSON-parsable
135
+ * value passes (TypeScript-only safety).
136
+ */
137
+ export function parseAsJson<T>(
138
+ guard?: (value: unknown) => value is T
139
+ ): QueryParserBuilder<T> {
140
+ return builder({
141
+ parse: (v) => {
142
+ try {
143
+ const parsed = JSON.parse(v) as unknown;
144
+ if (guard && !guard(parsed)) return null;
145
+ return parsed as T;
146
+ } catch {
147
+ return null;
148
+ }
149
+ },
150
+ serialize: (value) => JSON.stringify(value),
151
+ // Identity by JSON shape — fine for small objects, expensive for big ones.
152
+ eq: (a, b) => JSON.stringify(a) === JSON.stringify(b),
153
+ });
154
+ }
@@ -0,0 +1,145 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useBackOrFallback — smart "go back" that doesn't escape the app.
5
+ *
6
+ * WHY:
7
+ * `history.back()` on the first entry of a tab takes the user out of
8
+ * the app (or does nothing in some browsers). For in-app Back buttons
9
+ * we want: go back if there's in-app history, otherwise navigate to
10
+ * a sensible fallback.
11
+ *
12
+ * We track app-internal entries by stamping a monotonically growing
13
+ * serial into `history.state` on every navigation. On click we compare
14
+ * the current serial to the entry serial — if current > 0, there's
15
+ * an earlier app entry to go back to. This is bullet-proof against
16
+ * forward/back jitter (unlike a depth counter that can drift) and
17
+ * against `replace`-style navigations (they preserve the serial).
18
+ *
19
+ * @example
20
+ * const { back } = useBackOrFallback();
21
+ * <button onClick={() => back('/dashboard')}>Back</button>
22
+ */
23
+
24
+ import { useCallback, useEffect, useMemo } from 'react';
25
+ import { useNavigate } from './useNavigate';
26
+ import { NAVIGATE_EVENT } from './useLocation';
27
+
28
+ const SERIAL_KEY = '__djcSerial';
29
+ const COUNTER_KEY = '__djc_router_serial';
30
+
31
+ interface SerialState {
32
+ [SERIAL_KEY]?: number;
33
+ }
34
+
35
+ function readCounter(): number {
36
+ if (typeof window === 'undefined') return 0;
37
+ try {
38
+ const raw = window.sessionStorage.getItem(COUNTER_KEY);
39
+ if (!raw) return 0;
40
+ const parsed = Number.parseInt(raw, 10);
41
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
42
+ } catch {
43
+ return 0;
44
+ }
45
+ }
46
+
47
+ function writeCounter(value: number): void {
48
+ if (typeof window === 'undefined') return;
49
+ try {
50
+ window.sessionStorage.setItem(COUNTER_KEY, String(Math.max(0, value)));
51
+ } catch {
52
+ // sessionStorage can throw in privacy modes — degrade silently.
53
+ }
54
+ }
55
+
56
+ function readEntrySerial(): number {
57
+ if (typeof window === 'undefined') return 0;
58
+ const state = window.history.state as SerialState | null;
59
+ return typeof state?.[SERIAL_KEY] === 'number' ? state[SERIAL_KEY] : 0;
60
+ }
61
+
62
+ function stampEntrySerial(value: number): void {
63
+ if (typeof window === 'undefined') return;
64
+ const current = (window.history.state ?? {}) as SerialState;
65
+ // Don't double-stamp the same entry — `djc:navigate` can fire twice
66
+ // if a custom adapter wraps push too.
67
+ if (current[SERIAL_KEY] === value) return;
68
+ try {
69
+ window.history.replaceState(
70
+ { ...current, [SERIAL_KEY]: value },
71
+ '',
72
+ window.location.href
73
+ );
74
+ } catch {
75
+ // Some sandboxes disallow replaceState — skip.
76
+ }
77
+ }
78
+
79
+ // Module-level guard: a single listener stamps every new app entry.
80
+ let serialListenerAttached = false;
81
+
82
+ function attachSerialListener(): void {
83
+ if (serialListenerAttached) return;
84
+ if (typeof window === 'undefined') return;
85
+ serialListenerAttached = true;
86
+
87
+ const onNavigate = () => {
88
+ // If the current entry already has a serial (e.g. `replace`), keep it.
89
+ // Otherwise stamp a fresh one and bump the counter.
90
+ if (readEntrySerial() > 0) return;
91
+ const next = readCounter() + 1;
92
+ writeCounter(next);
93
+ stampEntrySerial(next);
94
+ };
95
+
96
+ // Stamp the very first entry so popstate-only flows still work.
97
+ if (readEntrySerial() === 0) {
98
+ const next = readCounter() + 1;
99
+ writeCounter(next);
100
+ stampEntrySerial(next);
101
+ }
102
+
103
+ window.addEventListener(NAVIGATE_EVENT, onNavigate);
104
+ }
105
+
106
+ export interface UseBackOrFallbackReturn {
107
+ /**
108
+ * Go back if we have in-app history, otherwise navigate to `fallback`.
109
+ * @param fallback - Where to go when there's no app history. Defaults to `/`.
110
+ */
111
+ back: (fallback?: string) => void;
112
+ /** True when there's an earlier app entry to go back to. */
113
+ canGoBack: boolean;
114
+ }
115
+
116
+ /**
117
+ * Smart "back" that falls back to a route when the user landed
118
+ * directly on the current page (no in-app history).
119
+ */
120
+ export function useBackOrFallback(): UseBackOrFallbackReturn {
121
+ const { back: adapterBack, navigate } = useNavigate();
122
+
123
+ useEffect(() => {
124
+ attachSerialListener();
125
+ }, []);
126
+
127
+ const back = useCallback(
128
+ (fallback: string = '/') => {
129
+ // Serial > 1 means at least one earlier app entry exists.
130
+ if (readEntrySerial() > 1) {
131
+ adapterBack();
132
+ } else {
133
+ navigate(fallback, { replace: true });
134
+ }
135
+ },
136
+ [adapterBack, navigate]
137
+ );
138
+
139
+ // Snapshot once per render — back/forward changes the serial on the
140
+ // entry we're sitting on, so we'll get a fresh value next render
141
+ // anyway via React's normal re-render cycle.
142
+ const canGoBack = readEntrySerial() > 1;
143
+
144
+ return useMemo(() => ({ back, canGoBack }), [back, canGoBack]);
145
+ }
@@ -0,0 +1,60 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useIsActive — boolean for "current pathname matches this href".
5
+ *
6
+ * WHY:
7
+ * Highlighting the active item in a navbar / sidebar is the most-copy-
8
+ * pasted snippet in any SPA: `pathname === href || pathname.startsWith(href + '/')`.
9
+ * This hook bakes in the right semantics (exact-vs-prefix, trailing-slash
10
+ * tolerance, query/hash ignoring) so consumers don't have to reinvent them.
11
+ *
12
+ * @example
13
+ * const isActive = useIsActive('/dashboard');
14
+ * const isProductsActive = useIsActive('/products', { exact: false });
15
+ * <Link className={isActive ? 'active' : ''}>...</Link>
16
+ */
17
+
18
+ import { useMemo } from 'react';
19
+ import { useLocation } from './useLocation';
20
+
21
+ export interface UseIsActiveOptions {
22
+ /**
23
+ * `true` (default) — match only when pathname === href.
24
+ * `false` — match when pathname is href OR a sub-path (`href/...`).
25
+ * Most navbar items want `false`; tab strips usually want `true`.
26
+ */
27
+ exact?: boolean;
28
+ /**
29
+ * Strip a trailing slash from both sides before comparing.
30
+ * Default: true.
31
+ */
32
+ ignoreTrailingSlash?: boolean;
33
+ }
34
+
35
+ function normalize(path: string, ignoreTrailingSlash: boolean): string {
36
+ if (!ignoreTrailingSlash) return path;
37
+ if (path.length > 1 && path.endsWith('/')) return path.slice(0, -1);
38
+ return path;
39
+ }
40
+
41
+ /**
42
+ * Returns `true` if `href` matches the current pathname.
43
+ * Pure path-based — search and hash are ignored.
44
+ */
45
+ export function useIsActive(href: string, options?: UseIsActiveOptions): boolean {
46
+ const { pathname } = useLocation();
47
+ const exact = options?.exact ?? false;
48
+ const ignoreTrailingSlash = options?.ignoreTrailingSlash ?? true;
49
+
50
+ return useMemo(() => {
51
+ // Strip search/hash from href if the consumer passed a full URL.
52
+ const cleanHref = href.split('?')[0]?.split('#')[0] ?? href;
53
+ const a = normalize(pathname, ignoreTrailingSlash);
54
+ const b = normalize(cleanHref, ignoreTrailingSlash);
55
+ if (a === b) return true;
56
+ if (exact) return false;
57
+ // Sub-path match: prefix + boundary so `/foobar` doesn't match `/foo`.
58
+ return a.startsWith(b + '/');
59
+ }, [pathname, href, exact, ignoreTrailingSlash]);
60
+ }
@@ -0,0 +1,163 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useLocation — reactive snapshot of `window.location`.
5
+ *
6
+ * WHY:
7
+ * Native `popstate` only fires on back/forward, NOT on `pushState` /
8
+ * `replaceState`. To make SPA navigations observable we monkey-patch
9
+ * those two methods once (idempotent) and dispatch a custom
10
+ * `djc:navigate` event after each call. All router hooks, the default
11
+ * adapter, and any code that calls history.* APIs anywhere in the page
12
+ * will trigger this event automatically.
13
+ *
14
+ * We use `useSyncExternalStore` because that is the React-19-blessed
15
+ * way to bridge external mutable state (window.location) into React's
16
+ * render model — it gets concurrent-render and SSR right (via
17
+ * getServerSnapshot).
18
+ *
19
+ * @example
20
+ * const { pathname, search, hash, href } = useLocation();
21
+ * useEffect(() => { analytics.page(pathname); }, [pathname]);
22
+ */
23
+
24
+ import { useSyncExternalStore } from 'react';
25
+
26
+ /**
27
+ * Browser-event name we dispatch when pushState/replaceState run.
28
+ * `pushState` and `replaceState` events fire alongside this for consumers
29
+ * that want to distinguish between the two history operations (wouter
30
+ * uses the same convention: separate per-method events PLUS a generic one).
31
+ */
32
+ export const NAVIGATE_EVENT = 'djc:navigate';
33
+ export const PUSH_STATE_EVENT = 'pushState';
34
+ export const REPLACE_STATE_EVENT = 'replaceState';
35
+
36
+ /** Frozen snapshot returned to React. Identity changes only on URL change. */
37
+ export interface LocationSnapshot {
38
+ pathname: string;
39
+ search: string;
40
+ hash: string;
41
+ href: string;
42
+ }
43
+
44
+ const SSR_SNAPSHOT: LocationSnapshot = Object.freeze({
45
+ pathname: '/',
46
+ search: '',
47
+ hash: '',
48
+ href: '/',
49
+ });
50
+
51
+ // Patch-once guard via a Symbol on `window`. We use a Symbol-on-window
52
+ // (wouter's pattern) instead of tagging the function because it survives
53
+ // other libraries re-patching the methods on top of us — once anybody
54
+ // (us or them) has installed a patch, the marker stays until full reload.
55
+ const PATCH_KEY = Symbol.for('djc.router.historyPatched');
56
+
57
+ function patchHistoryOnce(): void {
58
+ if (typeof window === 'undefined') return;
59
+ const w = window as Window & { [PATCH_KEY]?: true };
60
+ if (w[PATCH_KEY]) return;
61
+
62
+ const originalPush = window.history.pushState.bind(window.history);
63
+ const originalReplace = window.history.replaceState.bind(window.history);
64
+
65
+ window.history.pushState = function patchedPushState(
66
+ ...args: Parameters<History['pushState']>
67
+ ) {
68
+ const result = originalPush(...args);
69
+ // Two events: the per-method one (consumers can listen narrowly)
70
+ // and a generic NAVIGATE_EVENT for "any url change" subscribers.
71
+ window.dispatchEvent(new Event(PUSH_STATE_EVENT));
72
+ window.dispatchEvent(new Event(NAVIGATE_EVENT));
73
+ return result;
74
+ };
75
+
76
+ window.history.replaceState = function patchedReplaceState(
77
+ ...args: Parameters<History['replaceState']>
78
+ ) {
79
+ const result = originalReplace(...args);
80
+ window.dispatchEvent(new Event(REPLACE_STATE_EVENT));
81
+ window.dispatchEvent(new Event(NAVIGATE_EVENT));
82
+ return result;
83
+ };
84
+
85
+ Object.defineProperty(w, PATCH_KEY, { value: true });
86
+ }
87
+
88
+ // Cache the snapshot so getSnapshot returns the same reference between
89
+ // notifications. useSyncExternalStore demands referential stability between
90
+ // reads — recomputing the object on every call would loop in React 18+.
91
+ let cachedSnapshot: LocationSnapshot = SSR_SNAPSHOT;
92
+
93
+ function readLocation(): LocationSnapshot {
94
+ if (typeof window === 'undefined') return SSR_SNAPSHOT;
95
+ const { pathname, search, hash, href } = window.location;
96
+ // Only mint a new object if something actually changed.
97
+ if (
98
+ cachedSnapshot.pathname === pathname &&
99
+ cachedSnapshot.search === search &&
100
+ cachedSnapshot.hash === hash &&
101
+ cachedSnapshot.href === href
102
+ ) {
103
+ return cachedSnapshot;
104
+ }
105
+ cachedSnapshot = Object.freeze({ pathname, search, hash, href });
106
+ return cachedSnapshot;
107
+ }
108
+
109
+ function subscribe(onChange: () => void): () => void {
110
+ if (typeof window === 'undefined') return () => {};
111
+ patchHistoryOnce();
112
+ // Wrap the callback so both popstate and our custom event refresh
113
+ // the cached snapshot before React re-reads it.
114
+ const handler = () => {
115
+ // Force re-read on next getSnapshot call by resetting the cache.
116
+ // We intentionally don't read here — getSnapshot will compare and
117
+ // return a stable ref if nothing changed (e.g. duplicate events).
118
+ onChange();
119
+ };
120
+ window.addEventListener('popstate', handler);
121
+ window.addEventListener(NAVIGATE_EVENT, handler);
122
+ // hashchange covers programmatic location.hash assignments that bypass
123
+ // pushState. Cheap to subscribe to.
124
+ window.addEventListener('hashchange', handler);
125
+ return () => {
126
+ window.removeEventListener('popstate', handler);
127
+ window.removeEventListener(NAVIGATE_EVENT, handler);
128
+ window.removeEventListener('hashchange', handler);
129
+ };
130
+ }
131
+
132
+ function getServerSnapshot(): LocationSnapshot {
133
+ return SSR_SNAPSHOT;
134
+ }
135
+
136
+ /**
137
+ * Reactive snapshot of the current URL.
138
+ * Re-renders the calling component on every history navigation
139
+ * (pushState / replaceState / back / forward / hashchange).
140
+ *
141
+ * If you only need ONE field (e.g. just pathname), prefer
142
+ * `useLocationProperty(() => location.pathname)` — it subscribes to
143
+ * the same store but lets React skip re-renders when other fields
144
+ * change. Same pattern as `wouter`'s `usePathname` / `useSearch`.
145
+ */
146
+ export function useLocation(): LocationSnapshot {
147
+ return useSyncExternalStore(subscribe, readLocation, getServerSnapshot);
148
+ }
149
+
150
+ /**
151
+ * Subscribe to one derived value from `window.location`. React only
152
+ * re-renders when the returned value changes — so a component reading
153
+ * just `pathname` won't re-render on `?page=2` updates.
154
+ *
155
+ * @example
156
+ * const pathname = useLocationProperty(() => window.location.pathname, () => '/');
157
+ */
158
+ export function useLocationProperty<T>(
159
+ getValue: () => T,
160
+ getServerValue: () => T
161
+ ): T {
162
+ return useSyncExternalStore(subscribe, getValue, getServerValue);
163
+ }
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useNavigate — programmatic navigation primitive.
5
+ *
6
+ * WHY:
7
+ * Wraps the active adapter's push/replace and adds the small ergonomics
8
+ * we want on every nav: optional scroll-to-top, replace flag, and a
9
+ * distinct `navigateExternal` for full-reload flows (logout, OAuth,
10
+ * cross-origin redirects) where SPA navigation would be wrong.
11
+ *
12
+ * No `useTransition` here — that's a Next/React-internal concern; if
13
+ * the consumer wants pending state they wrap our calls themselves.
14
+ *
15
+ * @example
16
+ * const { navigate, navigateExternal } = useNavigate();
17
+ * navigate('/dashboard');
18
+ * navigate('/dashboard', { replace: true, scroll: false });
19
+ * navigateExternal('/api/auth/logout');
20
+ */
21
+
22
+ import { useCallback, useMemo } from 'react';
23
+ import { useRouterAdapter } from './adapter';
24
+
25
+ export interface NavigateOptions {
26
+ /** Use `replaceState` instead of `pushState`. Default: false. */
27
+ replace?: boolean;
28
+ /**
29
+ * Scroll to top after navigation. Default: false.
30
+ *
31
+ * Most SPA flows (filter/pagination/tab switches) shouldn't jump,
32
+ * which is why this is opt-in. Pass `true` for top-level page
33
+ * transitions where you want the user back at the masthead.
34
+ */
35
+ scroll?: boolean;
36
+ }
37
+
38
+ export interface UseNavigateReturn {
39
+ /**
40
+ * SPA navigation through the active adapter.
41
+ * Default: pushState + scroll to top.
42
+ */
43
+ navigate: (href: string, opts?: NavigateOptions) => void;
44
+ /**
45
+ * Hard navigation via `window.location.assign` — full page reload.
46
+ * Use for logout, OAuth, or any cross-origin handoff.
47
+ */
48
+ navigateExternal: (href: string) => void;
49
+ /** Pass-through: adapter.push */
50
+ push: (url: string) => void;
51
+ /** Pass-through: adapter.replace */
52
+ replace: (url: string) => void;
53
+ /** Pass-through: adapter.back */
54
+ back: () => void;
55
+ /** Pass-through: adapter.forward */
56
+ forward: () => void;
57
+ }
58
+
59
+ /**
60
+ * Returns stable navigation functions backed by the active router adapter.
61
+ * Safe to put returned functions in deps arrays.
62
+ */
63
+ export function useNavigate(): UseNavigateReturn {
64
+ const adapter = useRouterAdapter();
65
+
66
+ const navigate = useCallback(
67
+ (href: string, opts?: NavigateOptions) => {
68
+ const replace = opts?.replace ?? false;
69
+ const scroll = opts?.scroll ?? false;
70
+ if (replace) {
71
+ adapter.replace(href);
72
+ } else {
73
+ adapter.push(href);
74
+ }
75
+ if (scroll && typeof window !== 'undefined') {
76
+ window.scrollTo(0, 0);
77
+ }
78
+ },
79
+ [adapter]
80
+ );
81
+
82
+ const navigateExternal = useCallback((href: string) => {
83
+ if (typeof window === 'undefined') return;
84
+ window.location.assign(href);
85
+ }, []);
86
+
87
+ const push = useCallback((url: string) => adapter.push(url), [adapter]);
88
+ const replace = useCallback((url: string) => adapter.replace(url), [adapter]);
89
+ const back = useCallback(() => adapter.back(), [adapter]);
90
+ const forward = useCallback(() => adapter.forward(), [adapter]);
91
+
92
+ return useMemo<UseNavigateReturn>(
93
+ () => ({ navigate, navigateExternal, push, replace, back, forward }),
94
+ [navigate, navigateExternal, push, replace, back, forward]
95
+ );
96
+ }