@native-router/react 1.6.2 → 1.7.0

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 CHANGED
@@ -79,7 +79,7 @@ function Preview({visible}: {visible: boolean}) {
79
79
  - Type-safe links: `createRoutes(routes)` checks the table while keeping every `path` literal, `RoutePaths<typeof routes>` extracts the pattern union(through nesting and param segments), and `<TypedLink<RoutePaths<...>> to params>` narrows `to` to the table and checks `params` against the exact pattern's segments — compile errors for unknown paths and missing/wrong params, click-time interpolation with encoding as the runtime backstop
80
80
  - `ScrollRestoration` restores the scroll offset per history entry on back/forward and resets it on push (`resetOnPush` to opt out)
81
81
  - Router-level `preload(router, to)` shares resolved views across links with in-flight dedup and a 30s TTL; `PrefetchLink` prefetch through it
82
- - Hooks: `useRouter`, `useView`, `useData<T>(name?)` (typed data of the current level, or named data of ancestor routes), `useMatched` (matched levels, params, location), `useLoading`, `usePrefetch`, `useSearch(schema?)`, `useSetSearch(schema)`, `useBlocker(fn)` (unsaved-changes guard: the core `setBlocker` veto, registered while the component is mounted and always asked through the latest closure)
82
+ - Hooks: `useRouter`, `useView`, `useData<T>(name?)` (typed data of the current level, or named data of ancestor routes), `useMatched` (matched levels, params, location), `useLoading`, `usePrefetch`, `useSearch(schema?)`, `useSetSearch(schema)`, `useBlocker(fn)` (unsaved-changes guard: the core `setBlocker` veto, registered while the component is mounted and always asked through the latest closure; every veto is tracked on the returned `blocker.state` with a `proceed()`/`reset()` channel — `proceed()` retries the vetoed navigation bypassing this hook's blocker only, so the confirm dialog is a three-liner)
83
83
  - Two error layers, both phases: global `errorHandler` prop on the Router, per-route `errorComponent` receiving `{error, ctx}` — `errorComponent` renders for resolve failures(loader/guard/search, no `ctx.phase`) AND for render errors thrown by the component subtree(`ctx.phase === 'render'`, caught by a route-level error boundary so a rendering crash never escapes past its route, like the browser's error page for any failed load)
84
84
  - Route-level `pendingComponent` skeleton, shown only when no previous view can be retained (cold start, refresh, re-navigation after an error); the nearest matched ancestor's wins, and in-app navigation keeps the previous view instead
85
85
  - Keeping the previous view during in-app navigation is an intentional design following browser-native semantics — see the Design Principles section of the core repository's README
@@ -1 +1 @@
1
- {"version":3,"file":"create-routes.cjs","names":[],"sources":["../src/create-routes.js"],"sourcesContent":["/**\n * Identity function with `satisfies` semantics: the table is checked\n * against `Route` while every `path` keeps its string-literal type, so\n * `RoutePaths<typeof routes>` can extract the full pattern union for\n * `TypedLink`. An `as Route` assertion does the opposite — it widens\n * every `path` to `string` and gives up the literals.\n *\n * ```tsx\n * const routes = createRoutes({\n * children: [\n * {path: '/', component: () => import('./Home')},\n * {path: '/users/:id', component: () => import('./UserProfile')}\n * ]\n * });\n * // type AppPaths = '/' | '/users/:id'\n * type AppPaths = RoutePaths<typeof routes>;\n * ```\n *\n * Zero runtime cost: the function returns its argument unchanged and\n * tree-shakes away.\n * @group Methods\n * @category Route\n * @param routes the route table, a route object or an array of them\n * @returns the very same route table, literal types preserved\n */\nexport function createRoutes(\n // The `const` modifier keeps every `path` a string literal(through\n // arbitrary nesting) while the parameter is still checked against\n // `Route` — the `satisfies` semantics an `as Route` assertion lacks:\n // the assertion widens every `path` to `string` instead.\n routes\n) {\n return routes;\n}\n"],"mappings":"qBAyBA,SAKE,GAEA,OAAO,CACT"}
1
+ {"version":3,"file":"create-routes.cjs","names":[],"sources":["../src/create-routes.ts"],"sourcesContent":["import type {Route, SearchRoutesOf} from '@@/types';\n\n/**\n * Identity function with `satisfies` semantics: the table is checked\n * against `Route` while every `path` keeps its string-literal type, so\n * `RoutePaths<typeof routes>` can extract the full pattern union for\n * `TypedLink`. An `as Route` assertion does the opposite — it widens\n * every `path` to `string` and gives up the literals.\n *\n * The return type additionally closes the search loop(see\n * {@link SearchRoutesOf}): every level's `data` loader and `beforeLoad`\n * guard receive their `ctx.search` typed from the level's own\n * {@link Route.search search schema} —\n *\n * ```tsx\n * const routes = createRoutes({\n * children: [\n * {\n * path: '/list',\n * search: z.object({page: z.coerce.number()}),\n * // typeof routes → ctx.search: {page: number}, no annotations\n * data: ({search}) => fetchList(search.page)\n * }\n * ]\n * });\n * ```\n *\n * Callbacks written inside the literal are still checked loosely\n * against `Route`(`ctx.search: any` — TypeScript cannot contextually\n * type a member from sibling properties); the precise types hold on the\n * returned table, and a callback whose annotation contradicts the\n * schema is rejected at the property. An explicit `Route<P, S>` generic\n * keeps priority wherever it is written.\n *\n * Zero runtime cost: the function returns its argument unchanged and\n * tree-shakes away.\n * @group Methods\n * @category Route\n * @param routes the route table, a route object or an array of them\n * @returns the very same route table, literal types preserved and the\n * loader/guard search contexts re-typed from the schemas\n */\nexport function createRoutes<const T>(\n // The `const` modifier keeps every `path` a string literal(through\n // arbitrary nesting) while the parameter is still checked against\n // `Route` — the `satisfies` semantics an `as Route` assertion lacks:\n // the assertion widens every `path` to `string` instead. The\n // `SearchRoutesOf<T>` member re-types the level contexts on the\n // checked argument, so an annotation that contradicts the level's\n // schema fails right at the property.\n routes: T & SearchRoutesOf<T> & (Route | Route[])\n): SearchRoutesOf<T> {\n return routes;\n}\n"],"mappings":"qBA0CA,SAQE,GAEA,OAAO,CACT"}
@@ -1 +1 @@
1
- {"version":3,"file":"create-routes.js","names":[],"sources":["../src/create-routes.js"],"sourcesContent":["/**\n * Identity function with `satisfies` semantics: the table is checked\n * against `Route` while every `path` keeps its string-literal type, so\n * `RoutePaths<typeof routes>` can extract the full pattern union for\n * `TypedLink`. An `as Route` assertion does the opposite — it widens\n * every `path` to `string` and gives up the literals.\n *\n * ```tsx\n * const routes = createRoutes({\n * children: [\n * {path: '/', component: () => import('./Home')},\n * {path: '/users/:id', component: () => import('./UserProfile')}\n * ]\n * });\n * // type AppPaths = '/' | '/users/:id'\n * type AppPaths = RoutePaths<typeof routes>;\n * ```\n *\n * Zero runtime cost: the function returns its argument unchanged and\n * tree-shakes away.\n * @group Methods\n * @category Route\n * @param routes the route table, a route object or an array of them\n * @returns the very same route table, literal types preserved\n */\nexport function createRoutes(\n // The `const` modifier keeps every `path` a string literal(through\n // arbitrary nesting) while the parameter is still checked against\n // `Route` — the `satisfies` semantics an `as Route` assertion lacks:\n // the assertion widens every `path` to `string` instead.\n routes\n) {\n return routes;\n}\n"],"mappings":"AAyBA,SAAgB,EAKd,GAEA,OAAO,CACT"}
1
+ {"version":3,"file":"create-routes.js","names":[],"sources":["../src/create-routes.ts"],"sourcesContent":["import type {Route, SearchRoutesOf} from '@@/types';\n\n/**\n * Identity function with `satisfies` semantics: the table is checked\n * against `Route` while every `path` keeps its string-literal type, so\n * `RoutePaths<typeof routes>` can extract the full pattern union for\n * `TypedLink`. An `as Route` assertion does the opposite — it widens\n * every `path` to `string` and gives up the literals.\n *\n * The return type additionally closes the search loop(see\n * {@link SearchRoutesOf}): every level's `data` loader and `beforeLoad`\n * guard receive their `ctx.search` typed from the level's own\n * {@link Route.search search schema} —\n *\n * ```tsx\n * const routes = createRoutes({\n * children: [\n * {\n * path: '/list',\n * search: z.object({page: z.coerce.number()}),\n * // typeof routes → ctx.search: {page: number}, no annotations\n * data: ({search}) => fetchList(search.page)\n * }\n * ]\n * });\n * ```\n *\n * Callbacks written inside the literal are still checked loosely\n * against `Route`(`ctx.search: any` — TypeScript cannot contextually\n * type a member from sibling properties); the precise types hold on the\n * returned table, and a callback whose annotation contradicts the\n * schema is rejected at the property. An explicit `Route<P, S>` generic\n * keeps priority wherever it is written.\n *\n * Zero runtime cost: the function returns its argument unchanged and\n * tree-shakes away.\n * @group Methods\n * @category Route\n * @param routes the route table, a route object or an array of them\n * @returns the very same route table, literal types preserved and the\n * loader/guard search contexts re-typed from the schemas\n */\nexport function createRoutes<const T>(\n // The `const` modifier keeps every `path` a string literal(through\n // arbitrary nesting) while the parameter is still checked against\n // `Route` — the `satisfies` semantics an `as Route` assertion lacks:\n // the assertion widens every `path` to `string` instead. The\n // `SearchRoutesOf<T>` member re-types the level contexts on the\n // checked argument, so an annotation that contradicts the level's\n // schema fails right at the property.\n routes: T & SearchRoutesOf<T> & (Route | Route[])\n): SearchRoutesOf<T> {\n return routes;\n}\n"],"mappings":"AA0CA,SAAgB,EAQd,GAEA,OAAO,CACT"}
@@ -1,4 +1,34 @@
1
1
  import type { BlockerFn } from '@native-router/core';
2
+ /**
3
+ * A vetoed navigation waiting for a decision, exposed on
4
+ * {@link Blocker.state}: the user was asked, the router stayed on
5
+ * {@link BlockerState.from from}.
6
+ */
7
+ export type BlockerState = {
8
+ /** The vetoed navigation's target path (pathname, search, hash). */
9
+ location: string;
10
+ /** The path the vetoed navigation tried to leave. */
11
+ from: string;
12
+ };
13
+ /**
14
+ * What {@link useBlocker} returns: whether a navigation is waiting for
15
+ * a decision, plus the decision channel. Both actions are no-ops while
16
+ * `state` is `null`.
17
+ */
18
+ export type Blocker = {
19
+ /** The pending ask, or `null` while nothing waits — drive your confirm UI off it. */
20
+ state: BlockerState | null;
21
+ /**
22
+ * Retry the vetoed navigation. Only this hook's own blocker is
23
+ * bypassed — the user has answered it — while other registered
24
+ * blockers and the guard chain still get asked on the retry. A
25
+ * blocked-by-another-blocker retry re-enters this hook as a fresh
26
+ * veto and re-opens the ask.
27
+ */
28
+ proceed(): void;
29
+ /** Dismiss the ask; the router stays where it is. */
30
+ reset(): void;
31
+ };
2
32
  /**
3
33
  * Block navigations away from the current page while the component is
4
34
  * mounted — the unsaved-changes guard.
@@ -17,9 +47,36 @@ import type { BlockerFn } from '@native-router/core';
17
47
  * here touches `window`, and the registration itself is an effect that
18
48
  * never runs on the server.
19
49
  *
50
+ * Every veto is tracked on the returned {@link Blocker}, so the
51
+ * confirm UI is a three-liner instead of a hand-rolled ref/state pair:
52
+ *
53
+ * ```tsx
54
+ * const blocker = useBlocker(() => isDirtyRef.current);
55
+ *
56
+ * return (
57
+ * <>
58
+ * <Editor />
59
+ * <ConfirmDialog
60
+ * open={blocker.state != null}
61
+ * onCancel={blocker.reset}
62
+ * onConfirm={blocker.proceed}
63
+ * />
64
+ * </>
65
+ * );
66
+ * ```
67
+ *
68
+ * `proceed()` retries the vetoed navigation bypassing this hook's own
69
+ * blocker only — other registered blockers (and the route guards) are
70
+ * still asked, in registration order. Note the retry is a fresh push
71
+ * navigation: for a vetoed browser POP it appends an entry rather than
72
+ * re-running the history traversal.
73
+ *
20
74
  * @group Hooks
21
75
  * @param fn blocker predicate; `to` is the target path, `from` the
22
- * current path
76
+ * current path. Return `false` to veto (and open the ask), `true` to
77
+ * let the navigation through
78
+ * @returns {@link Blocker} — the pending ask and its proceed/reset
79
+ * channel
23
80
  * @see {@link setBlocker}
24
81
  */
25
- export declare function useBlocker(fn: BlockerFn): void;
82
+ export declare function useBlocker(fn: BlockerFn): Blocker;
@@ -1,2 +1,2 @@
1
- const e=require("./components/Router.cjs");let r=require("react"),t=require("@native-router/core");exports.useBlocker=function(u){const c=e.useRouter(),o=(0,r.useRef)(u);o.current=u,(0,r.useEffect)(()=>(0,t.setBlocker)(c,(e,r)=>o.current(e,r)),[c])};
1
+ const e=require("./components/Router.cjs");let r=require("react"),t=require("@native-router/core");exports.useBlocker=function(u){const c=e.useRouter(),n=(0,r.useRef)(u);n.current=u;const[o,l]=(0,r.useState)(null),s=(0,r.useRef)(null),a=(0,r.useRef)(!1);return(0,r.useEffect)(()=>(0,t.setBlocker)(c,(e,r)=>a.current?(a.current=!1,!0):!!n.current(e,r)||(s.current={location:e,from:r},l({location:e,from:r}),!1)),[c]),{state:o,proceed:(0,r.useCallback)(()=>{const e=s.current;e&&(s.current=null,l(null),a.current=!0,(0,t.navigate)(c,e.location).catch(()=>{}))},[c]),reset:(0,r.useCallback)(()=>{s.current=null,l(null)},[])}};
2
2
  //# sourceMappingURL=use-blocker.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-blocker.cjs","names":[],"sources":["../src/use-blocker.ts"],"sourcesContent":["import {useEffect, useRef} from 'react';\nimport {setBlocker} from '@native-router/core';\nimport type {BlockerFn} from '@native-router/core';\nimport {useRouter} from './components/Router';\n\n/**\n * Block navigations away from the current page while the component is\n * mounted — the unsaved-changes guard.\n *\n * The predicate is the core `setBlocker` veto: `(to, from) => boolean`\n * over path strings(including search and hash), asked synchronously at\n * the head of every navigation and before a history POP lands. Return\n * `false` to veto: a vetoed navigation never starts and a vetoed POP is\n * rewound. `refresh` and guard redirects are never blocked; the effect\n * releases the blocker on unmount, so the guard lives exactly as long\n * as the guarding component.\n *\n * The predicate is stored in a ref and re-synced on every render, so a\n * navigation is always asked the latest closure — a `confirmed` flag it\n * captured works without re-registering anything. SSR-safe: nothing\n * here touches `window`, and the registration itself is an effect that\n * never runs on the server.\n *\n * @group Hooks\n * @param fn blocker predicate; `to` is the target path, `from` the\n * current path\n * @see {@link setBlocker}\n */\nexport function useBlocker(fn: BlockerFn): void {\n const router = useRouter();\n const fnRef = useRef(fn);\n // Always ask the latest closure: re-rendering with new state must not\n // require re-registering the blocker.\n fnRef.current = fn;\n useEffect(\n () => setBlocker(router, (to, from) => fnRef.current(to, from)),\n [router]\n );\n}\n"],"mappings":"sHA4BA,SAA2B,GACzB,MAAM,EAAS,EAAA,YACT,GAAA,EAAQ,EAAA,QAAO,GAGrB,EAAM,QAAU,GAChB,EAAA,EAAA,WAAA,KAAA,EACQ,EAAA,YAAW,EAAA,CAAS,EAAI,IAAS,EAAM,QAAQ,EAAI,IACzD,CAAC,GAEL"}
1
+ {"version":3,"file":"use-blocker.cjs","names":[],"sources":["../src/use-blocker.ts"],"sourcesContent":["import {useCallback, useEffect, useRef, useState} from 'react';\nimport {navigate, setBlocker} from '@native-router/core';\nimport type {BlockerFn} from '@native-router/core';\nimport {useRouter} from './components/Router';\n\n/**\n * A vetoed navigation waiting for a decision, exposed on\n * {@link Blocker.state}: the user was asked, the router stayed on\n * {@link BlockerState.from from}.\n */\nexport type BlockerState = {\n /** The vetoed navigation's target path (pathname, search, hash). */\n location: string;\n /** The path the vetoed navigation tried to leave. */\n from: string;\n};\n\n/**\n * What {@link useBlocker} returns: whether a navigation is waiting for\n * a decision, plus the decision channel. Both actions are no-ops while\n * `state` is `null`.\n */\nexport type Blocker = {\n /** The pending ask, or `null` while nothing waits — drive your confirm UI off it. */\n state: BlockerState | null;\n /**\n * Retry the vetoed navigation. Only this hook's own blocker is\n * bypassed — the user has answered it — while other registered\n * blockers and the guard chain still get asked on the retry. A\n * blocked-by-another-blocker retry re-enters this hook as a fresh\n * veto and re-opens the ask.\n */\n proceed(): void;\n /** Dismiss the ask; the router stays where it is. */\n reset(): void;\n};\n\n/**\n * Block navigations away from the current page while the component is\n * mounted — the unsaved-changes guard.\n *\n * The predicate is the core `setBlocker` veto: `(to, from) => boolean`\n * over path strings(including search and hash), asked synchronously at\n * the head of every navigation and before a history POP lands. Return\n * `false` to veto: a vetoed navigation never starts and a vetoed POP is\n * rewound. `refresh` and guard redirects are never blocked; the effect\n * releases the blocker on unmount, so the guard lives exactly as long\n * as the guarding component.\n *\n * The predicate is stored in a ref and re-synced on every render, so a\n * navigation is always asked the latest closure — a `confirmed` flag it\n * captured works without re-registering anything. SSR-safe: nothing\n * here touches `window`, and the registration itself is an effect that\n * never runs on the server.\n *\n * Every veto is tracked on the returned {@link Blocker}, so the\n * confirm UI is a three-liner instead of a hand-rolled ref/state pair:\n *\n * ```tsx\n * const blocker = useBlocker(() => isDirtyRef.current);\n *\n * return (\n * <>\n * <Editor />\n * <ConfirmDialog\n * open={blocker.state != null}\n * onCancel={blocker.reset}\n * onConfirm={blocker.proceed}\n * />\n * </>\n * );\n * ```\n *\n * `proceed()` retries the vetoed navigation bypassing this hook's own\n * blocker only — other registered blockers (and the route guards) are\n * still asked, in registration order. Note the retry is a fresh push\n * navigation: for a vetoed browser POP it appends an entry rather than\n * re-running the history traversal.\n *\n * @group Hooks\n * @param fn blocker predicate; `to` is the target path, `from` the\n * current path. Return `false` to veto (and open the ask), `true` to\n * let the navigation through\n * @returns {@link Blocker} — the pending ask and its proceed/reset\n * channel\n * @see {@link setBlocker}\n */\nexport function useBlocker(fn: BlockerFn): Blocker {\n const router = useRouter();\n const fnRef = useRef(fn);\n // Always ask the latest closure: re-rendering with new state must not\n // require re-registering the blocker.\n fnRef.current = fn;\n const [ask, setAsk] = useState<BlockerState | null>(null);\n // The pending ask, readable synchronously from proceed/reset — the\n // confirm callbacks must not depend on the render closure's freshness.\n const pendingRef = useRef<BlockerState | null>(null);\n // One-shot bypass for the proceed retry: set right before the retry\n // navigation, cleared once it has been asked. A plain ref — the flag\n // must be visible to the synchronously-asked blocker, not to React.\n const bypassRef = useRef(false);\n\n useEffect(\n () =>\n setBlocker(router, (to, from) => {\n // The proceed retry: the user already answered this blocker.\n // Still ask the other registered ones (setBlocker iterates the\n // registry in order) and reset the flag once we are asked.\n if (bypassRef.current) {\n bypassRef.current = false;\n return true;\n }\n if (fnRef.current(to, from)) return true;\n // Vetoed: open the ask. A navigation superseding an open ask\n // replaces it — the older target is never proceeded to.\n pendingRef.current = {location: to, from};\n setAsk({location: to, from});\n return false;\n }),\n [router]\n );\n\n const proceed = useCallback(() => {\n const pending = pendingRef.current;\n if (!pending) return;\n pendingRef.current = null;\n setAsk(null);\n bypassRef.current = true;\n void navigate(router, pending.location).catch(() => undefined);\n }, [router]);\n\n const reset = useCallback(() => {\n pendingRef.current = null;\n setAsk(null);\n }, []);\n\n return {state: ask, proceed, reset};\n}\n"],"mappings":"sHAuFA,SAA2B,GACzB,MAAM,EAAS,EAAA,YACT,GAAA,EAAQ,EAAA,QAAO,GAGrB,EAAM,QAAU,EAChB,MAAO,EAAK,IAAA,EAAU,EAAA,UAA8B,MAG9C,GAAA,EAAa,EAAA,QAA4B,MAIzC,GAAA,EAAY,EAAA,SAAO,GAoCzB,OAlCA,EAAA,EAAA,WAAA,KAAA,EAEI,EAAA,YAAW,EAAA,CAAS,EAAI,IAIlB,EAAU,SACZ,EAAU,SAAU,GACb,KAEL,EAAM,QAAQ,EAAI,KAGtB,EAAW,QAAU,CAAC,SAAU,EAAI,QACpC,EAAO,CAAC,SAAU,EAAI,UACf,IAEX,CAAC,IAiBI,CAAC,MAAO,EAAK,SAAA,EAdJ,EAAA,aAAA,KACd,MAAM,EAAU,EAAW,QACtB,IACL,EAAW,QAAU,KACrB,EAAO,MACP,EAAU,SAAU,GACpB,EAAK,EAAA,UAAS,EAAQ,EAAQ,UAAU,MAAA,UACvC,CAAC,IAOyB,OAAA,EALf,EAAA,aAAA,KACZ,EAAW,QAAU,KACrB,EAAO,OACN,IAGL"}
@@ -1,2 +1,2 @@
1
- import{useRouter as r}from"./components/Router.js";import{useEffect as o,useRef as t}from"react";import{setBlocker as e}from"@native-router/core";function n(n){const c=r(),m=t(n);m.current=n,o(()=>e(c,(r,o)=>m.current(r,o)),[c])}export{n as useBlocker};
1
+ import{useRouter as r}from"./components/Router.js";import{useCallback as t,useEffect as n,useRef as o,useState as c}from"react";import{navigate as e,setBlocker as u}from"@native-router/core";function l(l){const m=r(),i=o(l);i.current=l;const[a,s]=c(null),f=o(null),p=o(!1);return n(()=>u(m,(r,t)=>p.current?(p.current=!1,!0):!!i.current(r,t)||(f.current={location:r,from:t},s({location:r,from:t}),!1)),[m]),{state:a,proceed:t(()=>{const r=f.current;r&&(f.current=null,s(null),p.current=!0,e(m,r.location).catch(()=>{}))},[m]),reset:t(()=>{f.current=null,s(null)},[])}}export{l as useBlocker};
2
2
  //# sourceMappingURL=use-blocker.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-blocker.js","names":[],"sources":["../src/use-blocker.ts"],"sourcesContent":["import {useEffect, useRef} from 'react';\nimport {setBlocker} from '@native-router/core';\nimport type {BlockerFn} from '@native-router/core';\nimport {useRouter} from './components/Router';\n\n/**\n * Block navigations away from the current page while the component is\n * mounted — the unsaved-changes guard.\n *\n * The predicate is the core `setBlocker` veto: `(to, from) => boolean`\n * over path strings(including search and hash), asked synchronously at\n * the head of every navigation and before a history POP lands. Return\n * `false` to veto: a vetoed navigation never starts and a vetoed POP is\n * rewound. `refresh` and guard redirects are never blocked; the effect\n * releases the blocker on unmount, so the guard lives exactly as long\n * as the guarding component.\n *\n * The predicate is stored in a ref and re-synced on every render, so a\n * navigation is always asked the latest closure — a `confirmed` flag it\n * captured works without re-registering anything. SSR-safe: nothing\n * here touches `window`, and the registration itself is an effect that\n * never runs on the server.\n *\n * @group Hooks\n * @param fn blocker predicate; `to` is the target path, `from` the\n * current path\n * @see {@link setBlocker}\n */\nexport function useBlocker(fn: BlockerFn): void {\n const router = useRouter();\n const fnRef = useRef(fn);\n // Always ask the latest closure: re-rendering with new state must not\n // require re-registering the blocker.\n fnRef.current = fn;\n useEffect(\n () => setBlocker(router, (to, from) => fnRef.current(to, from)),\n [router]\n );\n}\n"],"mappings":"kJA4BA,SAAgB,EAAW,GACzB,MAAM,EAAS,IACT,EAAQ,EAAO,GAGrB,EAAM,QAAU,EAChB,EAAA,IACQ,EAAW,EAAA,CAAS,EAAI,IAAS,EAAM,QAAQ,EAAI,IACzD,CAAC,GAEL"}
1
+ {"version":3,"file":"use-blocker.js","names":[],"sources":["../src/use-blocker.ts"],"sourcesContent":["import {useCallback, useEffect, useRef, useState} from 'react';\nimport {navigate, setBlocker} from '@native-router/core';\nimport type {BlockerFn} from '@native-router/core';\nimport {useRouter} from './components/Router';\n\n/**\n * A vetoed navigation waiting for a decision, exposed on\n * {@link Blocker.state}: the user was asked, the router stayed on\n * {@link BlockerState.from from}.\n */\nexport type BlockerState = {\n /** The vetoed navigation's target path (pathname, search, hash). */\n location: string;\n /** The path the vetoed navigation tried to leave. */\n from: string;\n};\n\n/**\n * What {@link useBlocker} returns: whether a navigation is waiting for\n * a decision, plus the decision channel. Both actions are no-ops while\n * `state` is `null`.\n */\nexport type Blocker = {\n /** The pending ask, or `null` while nothing waits — drive your confirm UI off it. */\n state: BlockerState | null;\n /**\n * Retry the vetoed navigation. Only this hook's own blocker is\n * bypassed — the user has answered it — while other registered\n * blockers and the guard chain still get asked on the retry. A\n * blocked-by-another-blocker retry re-enters this hook as a fresh\n * veto and re-opens the ask.\n */\n proceed(): void;\n /** Dismiss the ask; the router stays where it is. */\n reset(): void;\n};\n\n/**\n * Block navigations away from the current page while the component is\n * mounted — the unsaved-changes guard.\n *\n * The predicate is the core `setBlocker` veto: `(to, from) => boolean`\n * over path strings(including search and hash), asked synchronously at\n * the head of every navigation and before a history POP lands. Return\n * `false` to veto: a vetoed navigation never starts and a vetoed POP is\n * rewound. `refresh` and guard redirects are never blocked; the effect\n * releases the blocker on unmount, so the guard lives exactly as long\n * as the guarding component.\n *\n * The predicate is stored in a ref and re-synced on every render, so a\n * navigation is always asked the latest closure — a `confirmed` flag it\n * captured works without re-registering anything. SSR-safe: nothing\n * here touches `window`, and the registration itself is an effect that\n * never runs on the server.\n *\n * Every veto is tracked on the returned {@link Blocker}, so the\n * confirm UI is a three-liner instead of a hand-rolled ref/state pair:\n *\n * ```tsx\n * const blocker = useBlocker(() => isDirtyRef.current);\n *\n * return (\n * <>\n * <Editor />\n * <ConfirmDialog\n * open={blocker.state != null}\n * onCancel={blocker.reset}\n * onConfirm={blocker.proceed}\n * />\n * </>\n * );\n * ```\n *\n * `proceed()` retries the vetoed navigation bypassing this hook's own\n * blocker only — other registered blockers (and the route guards) are\n * still asked, in registration order. Note the retry is a fresh push\n * navigation: for a vetoed browser POP it appends an entry rather than\n * re-running the history traversal.\n *\n * @group Hooks\n * @param fn blocker predicate; `to` is the target path, `from` the\n * current path. Return `false` to veto (and open the ask), `true` to\n * let the navigation through\n * @returns {@link Blocker} — the pending ask and its proceed/reset\n * channel\n * @see {@link setBlocker}\n */\nexport function useBlocker(fn: BlockerFn): Blocker {\n const router = useRouter();\n const fnRef = useRef(fn);\n // Always ask the latest closure: re-rendering with new state must not\n // require re-registering the blocker.\n fnRef.current = fn;\n const [ask, setAsk] = useState<BlockerState | null>(null);\n // The pending ask, readable synchronously from proceed/reset — the\n // confirm callbacks must not depend on the render closure's freshness.\n const pendingRef = useRef<BlockerState | null>(null);\n // One-shot bypass for the proceed retry: set right before the retry\n // navigation, cleared once it has been asked. A plain ref — the flag\n // must be visible to the synchronously-asked blocker, not to React.\n const bypassRef = useRef(false);\n\n useEffect(\n () =>\n setBlocker(router, (to, from) => {\n // The proceed retry: the user already answered this blocker.\n // Still ask the other registered ones (setBlocker iterates the\n // registry in order) and reset the flag once we are asked.\n if (bypassRef.current) {\n bypassRef.current = false;\n return true;\n }\n if (fnRef.current(to, from)) return true;\n // Vetoed: open the ask. A navigation superseding an open ask\n // replaces it — the older target is never proceeded to.\n pendingRef.current = {location: to, from};\n setAsk({location: to, from});\n return false;\n }),\n [router]\n );\n\n const proceed = useCallback(() => {\n const pending = pendingRef.current;\n if (!pending) return;\n pendingRef.current = null;\n setAsk(null);\n bypassRef.current = true;\n void navigate(router, pending.location).catch(() => undefined);\n }, [router]);\n\n const reset = useCallback(() => {\n pendingRef.current = null;\n setAsk(null);\n }, []);\n\n return {state: ask, proceed, reset};\n}\n"],"mappings":"+LAuFA,SAAgB,EAAW,GACzB,MAAM,EAAS,IACT,EAAQ,EAAO,GAGrB,EAAM,QAAU,EAChB,MAAO,EAAK,GAAU,EAA8B,MAG9C,EAAa,EAA4B,MAIzC,EAAY,GAAO,GAoCzB,OAlCA,EAAA,IAEI,EAAW,EAAA,CAAS,EAAI,IAIlB,EAAU,SACZ,EAAU,SAAU,GACb,KAEL,EAAM,QAAQ,EAAI,KAGtB,EAAW,QAAU,CAAC,SAAU,EAAI,QACpC,EAAO,CAAC,SAAU,EAAI,UACf,IAEX,CAAC,IAiBI,CAAC,MAAO,EAAK,QAdJ,EAAA,KACd,MAAM,EAAU,EAAW,QACtB,IACL,EAAW,QAAU,KACrB,EAAO,MACP,EAAU,SAAU,EACpB,EAAc,EAAQ,EAAQ,UAAU,MAAA,UACvC,CAAC,IAOyB,MALf,EAAA,KACZ,EAAW,QAAU,KACrB,EAAO,OACN,IAGL"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@native-router/react",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {