@djangocfg/ui-core 2.1.616 → 2.1.617

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.616",
3
+ "version": "2.1.617",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -138,8 +138,8 @@
138
138
  "check:docs": "node scripts/check-docs.mjs"
139
139
  },
140
140
  "peerDependencies": {
141
- "@djangocfg/api": "^2.1.616",
142
- "@djangocfg/i18n": "^2.1.616",
141
+ "@djangocfg/api": "^2.1.617",
142
+ "@djangocfg/i18n": "^2.1.617",
143
143
  "consola": "^3.4.2",
144
144
  "lucide-react": "^1.68.0",
145
145
  "moment": "^2.30.1",
@@ -214,10 +214,10 @@
214
214
  "vaul": "1.1.2"
215
215
  },
216
216
  "devDependencies": {
217
- "@djangocfg/api": "^2.1.616",
218
- "@djangocfg/eslint-config": "^2.1.616",
219
- "@djangocfg/i18n": "^2.1.616",
220
- "@djangocfg/typescript-config": "^2.1.616",
217
+ "@djangocfg/api": "^2.1.617",
218
+ "@djangocfg/eslint-config": "^2.1.617",
219
+ "@djangocfg/i18n": "^2.1.617",
220
+ "@djangocfg/typescript-config": "^2.1.617",
221
221
  "@storybook/react-vite": "^10.6.5",
222
222
  "@types/node": "^24.13.3",
223
223
  "@types/react": "19.3.0",
@@ -123,6 +123,14 @@ Later or nested entries win field by field; an explicit option on a call beats t
123
123
 
124
124
  The bar is ours: [`route-progress`](../../route-progress/README.md). The adapter starts it for programmatic navigation, link clicks are caught by the provider, and every committed navigation (a new pathname, or Next's `pushState`) ends it, with a 15 s ceiling, so a forced bar on a query change cannot hang. Without a `RouteProgressProvider` above (BaseApp mounts one) there is no bar and `useNavigationBehavior` does nothing.
125
125
 
126
+ ## Shallow writes (no navigation)
127
+
128
+ `useQueryState(key, parser, { shallow: true })`, or `setValue(v, { shallow: true })` for one call, writes the URL through the History API and nothing else: no request to the server, no server components re-rendered, no loading bar, no scroll (`scroll` and `progress` are ignored). For state only the client needs (a view mode, a zoom, a slider), above all on `force-dynamic` pages where every router call is a full server render. Precedence: the call, then the hook, then `false`. `replace` still picks `replaceState` or `pushState`; with `push`, Back and Forward restore the earlier value (`useLocation` listens to `popstate`). `useNavigate().shallow(href, { replace })` is the same write without the hook.
129
+
130
+ The adapter method is `RouterAdapter.shallow?(url, { replace })`; the default and Next adapters implement it, passing `null` as the history state (Next syncs `useSearchParams` only for entries without its own `__NA`/`_N` keys, so `history.state` must not be forwarded). An adapter without it falls back to `push`/`replace` with the bar and scroll off.
131
+
132
+ Setters read the URL at the moment of the call, not the one the last render saw, so two calls in one tick both land and a functional updater sees the earlier write (this holds for normal mode too, though there the URL changes only when the router commits).
133
+
126
134
  ## `useQueryState` (typed URL state)
127
135
 
128
136
  Bound to one query key with a typed parser. Inspired by `nuqs` but framework-agnostic via the same adapter context.
@@ -77,6 +77,15 @@ export interface RouterAdapter {
77
77
  forward: () => void;
78
78
  /** Read the current location (synchronous). */
79
79
  getLocation: () => RouterLocation;
80
+ /**
81
+ * Write the URL without a navigation: no request to the server, no server
82
+ * components re-rendered, no loading bar, no scroll. For state that only the
83
+ * client needs (a view mode, a zoom, a slider). Optional: when an adapter has
84
+ * none, callers fall back to `push`/`replace` with the bar and the scroll off.
85
+ * Implementations must pass `null` as the history state: Next syncs
86
+ * `useSearchParams` only for entries without its own router keys.
87
+ */
88
+ shallow?: (url: string, opts?: { replace?: boolean }) => void;
80
89
  }
81
90
 
82
91
  const SSR_LOCATION: RouterLocation = Object.freeze({
@@ -115,8 +124,23 @@ export const defaultAdapter: RouterAdapter = Object.freeze({
115
124
  hash: window.location.hash,
116
125
  };
117
126
  },
127
+ shallow(url: string, opts?: { replace?: boolean }) {
128
+ writeUrlShallow(url, opts?.replace ?? false);
129
+ },
118
130
  });
119
131
 
132
+ /**
133
+ * Write the URL through the History API and nothing else. The state is `null`,
134
+ * never `history.state`: Next marks its own entries (`__NA`, `_N`) and ignores
135
+ * the ones it did not make when it syncs `useSearchParams`/`usePathname`.
136
+ * Our patched `pushState`/`replaceState` tell `useLocation`, so hooks update.
137
+ */
138
+ export function writeUrlShallow(url: string, replace: boolean): void {
139
+ if (typeof window === 'undefined') return;
140
+ if (replace) window.history.replaceState(null, '', url);
141
+ else window.history.pushState(null, '', url);
142
+ }
143
+
120
144
  /**
121
145
  * React context carrying the active adapter. `null` means "use default".
122
146
  * Kept intentionally nullable so we don't burn a Provider for the default case.
@@ -50,6 +50,7 @@ import {
50
50
  type RouterAdapter,
51
51
  type RouterLocation,
52
52
  type RouterNavigateOptions,
53
+ writeUrlShallow,
53
54
  } from '../adapter';
54
55
  import { useRouteProgress } from '../../../route-progress';
55
56
  import { shouldStartProgress } from '../../../route-progress/provider/shouldStart';
@@ -132,6 +133,10 @@ export function NextRouterAdapter({ children, defaultScroll }: NextRouterAdapter
132
133
  startBar(url, opts);
133
134
  next.replace(url, nextOptions(opts));
134
135
  },
136
+ // No router call: no RSC request, no bar, no scroll. `null` state so Next syncs `useSearchParams`.
137
+ shallow(url, opts) {
138
+ writeUrlShallow(url, opts?.replace ?? false);
139
+ },
135
140
  back() {
136
141
  next.back();
137
142
  },
@@ -52,6 +52,12 @@ export interface UseNavigateReturn {
52
52
  * Use for logout, OAuth, or any cross-origin handoff.
53
53
  */
54
54
  navigateExternal: (href: string) => void;
55
+ /**
56
+ * Write the URL without a navigation (no server request, no bar, no scroll), see
57
+ * `RouterAdapter.shallow`. Falls back to `push`/`replace` with bar and scroll off
58
+ * when the adapter has no shallow write.
59
+ */
60
+ shallow: (href: string, opts?: { replace?: boolean }) => void;
55
61
  /** Pass-through: adapter.push */
56
62
  push: (url: string, opts?: RouterNavigateOptions) => void;
57
63
  /** Pass-through: adapter.replace */
@@ -86,6 +92,19 @@ export function useNavigate(): UseNavigateReturn {
86
92
  [adapter]
87
93
  );
88
94
 
95
+ const shallow = useCallback(
96
+ (href: string, opts?: { replace?: boolean }) => {
97
+ if (adapter.shallow) {
98
+ adapter.shallow(href, opts);
99
+ return;
100
+ }
101
+ const fallback = { scroll: false, progress: false };
102
+ if (opts?.replace) adapter.replace(href, fallback);
103
+ else adapter.push(href, fallback);
104
+ },
105
+ [adapter]
106
+ );
107
+
89
108
  const navigateExternal = useCallback((href: string) => {
90
109
  if (typeof window === 'undefined') return;
91
110
  window.location.assign(href);
@@ -97,7 +116,7 @@ export function useNavigate(): UseNavigateReturn {
97
116
  const forward = useCallback(() => adapter.forward(), [adapter]);
98
117
 
99
118
  return useMemo<UseNavigateReturn>(
100
- () => ({ navigate, navigateExternal, push, replace, back, forward }),
101
- [navigate, navigateExternal, push, replace, back, forward]
119
+ () => ({ navigate, navigateExternal, shallow, push, replace, back, forward }),
120
+ [navigate, navigateExternal, shallow, push, replace, back, forward]
102
121
  );
103
122
  }
@@ -20,6 +20,7 @@
20
20
  */
21
21
 
22
22
  import { useCallback, useMemo } from 'react';
23
+ import { useRouterAdapter } from './adapter';
23
24
  import { useLocation } from './useLocation';
24
25
  import { useNavigate } from './useNavigate';
25
26
  import type { QueryParser } from './parsers';
@@ -34,6 +35,13 @@ export interface UseQueryStateOptions {
34
35
  * transition. Pass `true` when the change loads a heavy server view.
35
36
  */
36
37
  progress?: boolean;
38
+ /**
39
+ * Write the URL without a navigation: no request to the server, no server
40
+ * components re-rendered, no bar, no scroll (`scroll` and `progress` are ignored).
41
+ * For state only the client needs (a view mode, a zoom, a slider). Default:
42
+ * false. Precedence: the setter call, then the hook, then false.
43
+ */
44
+ shallow?: boolean;
37
45
  /**
38
46
  * When the new value equals the parser's default, drop the key from
39
47
  * the URL instead of writing `?page=1`. Default: true (recommended —
@@ -64,25 +72,38 @@ export function useQueryState<T>(
64
72
  options?: UseQueryStateOptions
65
73
  ): [T | null, (next: QueryStateUpdater<T>, opts?: UseQueryStateOptions) => void] {
66
74
  const { pathname, search } = useLocation();
67
- const { navigate } = useNavigate();
75
+ const { navigate, shallow } = useNavigate();
76
+ const adapter = useRouterAdapter();
68
77
 
69
- const value = useMemo<T | null>(() => {
70
- const raw = new URLSearchParams(search).get(key);
71
- if (raw === null) return parser.defaultValue ?? null;
72
- const parsed = parser.parse(raw);
73
- return parsed === null ? (parser.defaultValue ?? null) : parsed;
74
- }, [search, key, parser]);
78
+ const parse = useCallback(
79
+ (qs: string): T | null => {
80
+ const raw = new URLSearchParams(qs).get(key);
81
+ if (raw === null) return parser.defaultValue ?? null;
82
+ const parsed = parser.parse(raw);
83
+ return parsed === null ? (parser.defaultValue ?? null) : parsed;
84
+ },
85
+ [key, parser]
86
+ );
87
+
88
+ const value = useMemo<T | null>(() => parse(search), [search, parse]);
75
89
 
76
90
  const setValue = useCallback(
77
91
  (next: QueryStateUpdater<T>, callOpts?: UseQueryStateOptions) => {
92
+ // Read the URL as it is now, not as this render saw it: two calls in one tick
93
+ // (or one before the re-render) must both land. The rendered `search` is the fallback on the server.
94
+ const live = typeof window === 'undefined' ? null : adapter.getLocation();
95
+ const currentSearch = live ? live.search : search;
96
+ const currentPathname = live ? live.pathname : pathname;
97
+ const current = live ? parse(currentSearch) : value;
98
+
78
99
  const resolved =
79
100
  typeof next === 'function'
80
101
  ? (next as (current: T) => T | null)(
81
- (value ?? parser.defaultValue) as T
102
+ (current ?? parser.defaultValue) as T
82
103
  )
83
104
  : next;
84
105
 
85
- const params = new URLSearchParams(search);
106
+ const params = new URLSearchParams(currentSearch);
86
107
  const clearOnDefault =
87
108
  callOpts?.clearOnDefault ?? options?.clearOnDefault ?? true;
88
109
 
@@ -98,14 +119,20 @@ export function useQueryState<T>(
98
119
  }
99
120
 
100
121
  const qs = params.toString();
101
- const href = qs ? `${pathname}?${qs}` : pathname;
122
+ const href = qs ? `${currentPathname}?${qs}` : currentPathname;
123
+ const replace = callOpts?.replace ?? options?.replace ?? false;
124
+ if (callOpts?.shallow ?? options?.shallow ?? false) {
125
+ // The hash goes with the entry, as `pushState` of a bare path would drop it.
126
+ shallow(live?.hash ? `${href}${live.hash}` : href, { replace });
127
+ return;
128
+ }
102
129
  navigate(href, {
103
- replace: callOpts?.replace ?? options?.replace ?? false,
130
+ replace,
104
131
  scroll: callOpts?.scroll ?? options?.scroll ?? false,
105
132
  progress: callOpts?.progress ?? options?.progress ?? false,
106
133
  });
107
134
  },
108
- [pathname, search, key, parser, navigate, options, value]
135
+ [adapter, pathname, search, key, parser, parse, navigate, shallow, options, value]
109
136
  );
110
137
 
111
138
  return [value, setValue];
@@ -65,6 +65,7 @@ export function useRouter(): UseRouterReturn {
65
65
  // Navigation pass-through
66
66
  navigate: nav.navigate,
67
67
  navigateExternal: nav.navigateExternal,
68
+ shallow: nav.shallow,
68
69
  push: nav.push,
69
70
  replace: nav.replace,
70
71
  back: nav.back,