@djangocfg/ui-core 2.1.293 → 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.
- package/README.md +50 -3
- package/package.json +16 -4
- package/src/hooks/index.ts +67 -0
- package/src/hooks/router/README.md +119 -0
- package/src/hooks/router/adapter.tsx +139 -0
- package/src/hooks/router/adapters/index.ts +5 -0
- package/src/hooks/router/adapters/nextjs.tsx +100 -0
- package/src/hooks/router/index.ts +90 -0
- package/src/hooks/router/parsers.ts +154 -0
- package/src/hooks/router/useBackOrFallback.ts +145 -0
- package/src/hooks/router/useIsActive.ts +60 -0
- package/src/hooks/router/useLocation.ts +163 -0
- package/src/hooks/router/useNavigate.ts +96 -0
- package/src/hooks/router/useQueryParams.ts +262 -0
- package/src/hooks/router/useQueryState.ts +106 -0
- package/src/hooks/router/useRouter.ts +81 -0
- package/src/hooks/router/useSmartLink.ts +157 -0
- package/src/hooks/router/useUrlBuilder.ts +118 -0
|
@@ -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
|
+
}
|