@nifrajs/web-react 3.5.0 → 4.0.2
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/dist/auth.d.ts +32 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +85 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +9 -24
- package/dist/client.js.map +1 -1
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +1 -0
- package/dist/compose.js.map +1 -1
- package/dist/i18n.d.ts +19 -6
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +27 -6
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -3
- package/dist/index.js.map +1 -1
- package/dist/react-dom-server.d.ts +3 -1
- package/dist/react-dom-server.d.ts.map +1 -1
- package/dist/react-dom-server.js.map +1 -1
- package/dist/render-error.d.ts +4 -0
- package/dist/render-error.d.ts.map +1 -0
- package/dist/render-error.js +15 -0
- package/dist/render-error.js.map +1 -0
- package/dist/route-props.d.ts +4 -0
- package/dist/route-props.d.ts.map +1 -0
- package/dist/route-props.js +32 -0
- package/dist/route-props.js.map +1 -0
- package/dist/router.d.ts +23 -3
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +29 -2
- package/dist/router.js.map +1 -1
- package/package.json +14 -5
- package/src/auth.ts +128 -0
- package/src/client.ts +9 -24
- package/src/compose.ts +1 -0
- package/src/i18n.ts +49 -9
- package/src/index.ts +15 -3
- package/src/react-dom-server.ts +4 -1
- package/src/render-error.ts +18 -0
- package/src/route-props.ts +37 -0
- package/src/router.ts +42 -4
package/dist/router.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// `/client`, not the root - these are DOM values, and the root's graph carries the
|
|
14
14
|
// server, which Vite's dev server evaluates rather than tree-shakes.
|
|
15
15
|
import { getBrowserNavigate, IDLE_BLOCKER, registerBlocker, resolveNavigate, } from "@nifrajs/web/client";
|
|
16
|
+
// Its own subpath, so an app that never calls `useMatches` bundles none of it.
|
|
17
|
+
import { chainMatches } from "@nifrajs/web/internal/matches-runtime";
|
|
16
18
|
import { createContext, createElement, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
17
19
|
// Frozen empty params/search so the default context value has a stable reference (no needless re-renders).
|
|
18
20
|
const EMPTY_PARAMS = Object.freeze({});
|
|
@@ -73,6 +75,24 @@ export function useParams() {
|
|
|
73
75
|
export function useSearch() {
|
|
74
76
|
return useContext(RouterContext).search;
|
|
75
77
|
}
|
|
78
|
+
const NO_MATCHES = Object.freeze([]);
|
|
79
|
+
/**
|
|
80
|
+
* The rendered chain, outermost layout first and the page last: each module's `id`, the URL
|
|
81
|
+
* `pathname` it wraps, its `params` and loader `data`, and its `handle` export. The same list on the
|
|
82
|
+
* server and in the browser, so a layout can render breadcrumbs or read a flag the page exports:
|
|
83
|
+
*
|
|
84
|
+
* ```tsx
|
|
85
|
+
* export const handle = { crumb: "Settings" } // in a page or a layout
|
|
86
|
+
* // in a layout above it:
|
|
87
|
+
* const crumbs = useMatches().flatMap((m) => (m.handle as { crumb?: string } | undefined)?.crumb ?? [])
|
|
88
|
+
* ```
|
|
89
|
+
*
|
|
90
|
+
* Empty outside a nifra route tree.
|
|
91
|
+
*/
|
|
92
|
+
export function useMatches() {
|
|
93
|
+
const props = useContext(RouterContext).renderProps;
|
|
94
|
+
return useMemo(() => (props === undefined ? NO_MATCHES : chainMatches(props)), [props]);
|
|
95
|
+
}
|
|
76
96
|
/** The current {@link Location} (`pathname`/`search`/`hash`), derived from the router context. */
|
|
77
97
|
export function useLocation() {
|
|
78
98
|
const { path } = useContext(RouterContext);
|
|
@@ -204,7 +224,7 @@ function isPlainLeftClick(event, target) {
|
|
|
204
224
|
* instead of a full reload. Calling `navigate` + `preventDefault` here means `installHistory`'s
|
|
205
225
|
* document-level click handler sees `defaultPrevented` and stands down - exactly one navigation.
|
|
206
226
|
*/
|
|
207
|
-
export const Link = forwardRef(function Link({ to, replace, onClick, target, ...rest }, ref) {
|
|
227
|
+
export const Link = forwardRef(function Link({ to, replace, prefetch, onClick, target, ...rest }, ref) {
|
|
208
228
|
const handleClick = (event) => {
|
|
209
229
|
onClick?.(event);
|
|
210
230
|
if (event.defaultPrevented)
|
|
@@ -217,7 +237,14 @@ export const Link = forwardRef(function Link({ to, replace, onClick, target, ...
|
|
|
217
237
|
event.preventDefault();
|
|
218
238
|
navigate(to, { replace: replace === true });
|
|
219
239
|
};
|
|
220
|
-
return createElement("a", {
|
|
240
|
+
return createElement("a", {
|
|
241
|
+
...rest,
|
|
242
|
+
href: to,
|
|
243
|
+
target,
|
|
244
|
+
onClick: handleClick,
|
|
245
|
+
ref,
|
|
246
|
+
...(prefetch !== undefined ? { "data-nifra-prefetch": prefetch } : {}),
|
|
247
|
+
});
|
|
221
248
|
});
|
|
222
249
|
/** Normalize a pathname for comparison: optional case-fold + drop a single trailing slash (but keep a
|
|
223
250
|
* bare `"/"`). */
|
package/dist/router.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;
|
|
1
|
+
{"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAiBH,mFAAmF;AACnF,qEAAqE;AACrE,OAAO,EACL,kBAAkB,EAClB,YAAY,EACZ,eAAe,EACf,eAAe,GAChB,MAAM,qBAAqB,CAAA;AAC5B,+EAA+E;AAC/E,OAAO,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAA;AACpE,OAAO,EAGL,aAAa,EACb,aAAa,EACb,UAAU,EAGV,WAAW,EACX,UAAU,EACV,SAAS,EACT,OAAO,EACP,MAAM,EACN,QAAQ,GACT,MAAM,OAAO,CAAA;AAyBd,2GAA2G;AAC3G,MAAM,YAAY,GAAqC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AACxE,MAAM,YAAY,GAAsC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAEzE;;;;;;;;;;;4DAW4D;AAC5D,MAAM,mBAAmB,GAAG,MAAM,CAAC,GAAG,CAAC,gCAAgC,CAAC,CAAA;AACxE,MAAM,WAAW,GAAG,UAEnB,CAAA;AACD,MAAM,CAAC,MAAM,aAAa,GACxB,WAAW,CAAC,mBAAmB,CAAC;IAChC,aAAa,CAAqB;QAChC,MAAM,EAAE,YAAY;QACpB,IAAI,EAAE,EAAE;QACR,MAAM,EAAE,YAAY;QACpB,OAAO,EAAE,KAAK;KACf,CAAC,CAAA;AACJ,WAAW,CAAC,mBAAmB,CAAC,GAAG,aAAa,CAAA;AAEhD;iCACiC;AACjC,SAAS,SAAS,CAAC,IAAY;IAC7B,MAAM,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;IAC3B,OAAO,CAAC,KAAK,CAAC,CAAC;QACb,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE;QAChC,CAAC,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAA;AAC3D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,SAAS;IAGvB,OAAO,UAAU,CAAC,aAAa,CAAC,CAAC,MAAqB,CAAA;AACxD,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,SAAS;IAGvB,OAAO,UAAU,CAAC,aAAa,CAAC,CAAC,MAEN,CAAA;AAC7B,CAAC;AAED,MAAM,UAAU,GAAuB,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;AAExD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,UAAU;IACxB,MAAM,KAAK,GAAG,UAAU,CAAC,aAAa,CAAC,CAAC,WAAW,CAAA;IACnD,OAAO,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAA;AACzF,CAAC;AAWD,kGAAkG;AAClG,MAAM,UAAU,WAAW;IACzB,MAAM,EAAE,IAAI,EAAE,GAAG,UAAU,CAAC,aAAa,CAAC,CAAA;IAC1C,OAAO,OAAO,CAAC,GAAG,EAAE;QAClB,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,IAAI,CAAC,CAAA;QAC5C,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,CAAA;IACvC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAA;AACZ,CAAC;AAcD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,GAAG,UAAU,CAAC,aAAa,CAAC,CAAA;IAC1D,OAAO,OAAO,CACZ,GAAG,EAAE,CAAC,CAAC;QACL,OAAO;QACP,KAAK,EAAE,OAAO,CAAC,CAAC,CAAE,SAAmB,CAAC,CAAC,CAAE,MAAgB;QACzD,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS;KAC5C,CAAC,EACF,CAAC,OAAO,EAAE,WAAW,CAAC,CACvB,CAAA;AACH,CAAC;AAED,wGAAwG;AACxG,MAAM,UAAU,UAAU;IACxB,OAAO,UAAU,CAAC,aAAa,CAAC,CAAC,OAAO,CAAA;AAC1C,CAAC;AAED;;;8CAG8C;AAC9C,MAAM,UAAU,WAAW;IACzB,OAAO,WAAW,CAAC,CAAC,EAAyC,EAAE,OAAyB,EAAE,EAAE;QAC1F,MAAM,QAAQ,GAAG,kBAAkB,EAAE,CAAA;QACrC,IAAI,QAAQ,KAAK,SAAS;YAAE,OAAM;QAClC,MAAM,QAAQ,GAAG,eAAe,CAAC,EAAE,EAAE,OAAO,CAAC,CAAA;QAC7C,QAAQ,CAAC,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;IACzC,CAAC,EAAE,EAAE,CAAqB,CAAA;AAC5B,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,UAAU,CAAC,WAAsC;IAC/D,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAAU,YAAY,CAAC,CAAA;IAC7D,oGAAoG;IACpG,wGAAwG;IACxG,MAAM,cAAc,GAAG,MAAM,CAAC,WAAW,CAAC,CAAA;IAC1C,cAAc,CAAC,OAAO,GAAG,WAAW,CAAA;IACpC,sGAAsG;IACtG,wGAAwG;IACxG,SAAS,CACP,GAAG,EAAE,CACH,eAAe,CAAC,CAAC,IAAI,EAAE,EAAE;QACvB,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,CAAA;QACtC,OAAO,OAAO,OAAO,KAAK,UAAU,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAA;IAChE,CAAC,EAAE,UAAU,CAAC,EAChB,EAAE,CACH,CAAA;IACD,OAAO,OAAO,CAAA;AAChB,CAAC;AAKD,SAAS,cAAc,CAAC,IAAsB;IAC5C,IAAI,IAAI,YAAY,eAAe;QAAE,OAAO,IAAI,CAAA;IAChD,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,IAAI,eAAe,CAAC,IAAI,CAAC,CAAA;IAC9D,MAAM,GAAG,GAAG,IAAI,eAAe,EAAE,CAAA;IACjC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAChD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;YAAE,KAAK,MAAM,IAAI,IAAI,KAAK;gBAAE,GAAG,CAAC,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,CAAA;;YACpE,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,KAAe,CAAC,CAAA;IACpC,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AASD;;;GAGG;AACH,MAAM,UAAU,eAAe;IAC7B,MAAM,EAAE,IAAI,EAAE,GAAG,UAAU,CAAC,aAAa,CAAC,CAAA;IAC1C,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC,IAAI,CAAC,CAAA;IAC5C,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,eAAe,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAA;IACzE,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAA;IAC9B,MAAM,eAAe,GAAG,WAAW,CACjC,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE;QAChB,MAAM,QAAQ,GAAG,OAAO,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;QACtF,MAAM,EAAE,GAAG,cAAc,CAAC,QAAQ,CAAC,CAAC,QAAQ,EAAE,CAAA;QAC9C,QAAQ,CAAC,QAAQ,GAAG,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,CAAA;IAC3D,CAAC,EACD,CAAC,QAAQ,EAAE,QAAQ,EAAE,MAAM,CAAC,CAC7B,CAAA;IACD,OAAO,CAAC,YAAY,EAAE,eAAe,CAAU,CAAA;AACjD,CAAC;AAED;+FAC+F;AAC/F,SAAS,gBAAgB,CAAC,KAAiB,EAAE,MAA0B;IACrE,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IACpC,IAAI,KAAK,CAAC,OAAO,IAAI,KAAK,CAAC,OAAO,IAAI,KAAK,CAAC,QAAQ,IAAI,KAAK,CAAC,MAAM;QAAE,OAAO,KAAK,CAAA;IAClF,OAAO,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE,IAAI,MAAM,KAAK,OAAO,CAAA;AACpE,CAAC;AAcD;;;;;GAKG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,UAAU,CAA+B,SAAS,IAAI,CACxE,EAAE,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,EACnD,GAAG;IAEH,MAAM,WAAW,GAAG,CAAC,KAAoC,EAAQ,EAAE;QACjE,OAAO,EAAE,CAAC,KAAK,CAAC,CAAA;QAChB,IAAI,KAAK,CAAC,gBAAgB;YAAE,OAAM,CAAC,0CAA0C;QAC7E,IAAI,CAAC,gBAAgB,CAAC,KAAK,EAAE,MAAM,CAAC;YAAE,OAAM,CAAC,oCAAoC;QACjF,MAAM,QAAQ,GAAG,kBAAkB,EAAE,CAAA;QACrC,IAAI,QAAQ,KAAK,SAAS;YAAE,OAAM,CAAC,oDAAoD;QACvF,KAAK,CAAC,cAAc,EAAE,CAAA;QACtB,QAAQ,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,EAAE,CAAC,CAAA;IAC7C,CAAC,CAAA;IACD,OAAO,aAAa,CAAC,GAAG,EAAE;QACxB,GAAG,IAAI;QACP,IAAI,EAAE,EAAE;QACR,MAAM;QACN,OAAO,EAAE,WAAW;QACpB,GAAG;QACH,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,qBAAqB,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvE,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAuBF;kBACkB;AAClB,SAAS,aAAa,CAAC,QAAgB,EAAE,aAAsB;IAC7D,MAAM,KAAK,GAAG,aAAa,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAA;IAC/D,OAAO,KAAK,KAAK,GAAG,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;AAC1E,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,UAAU,CAAkC,SAAS,OAAO,CACjF,EAAE,EAAE,EAAE,GAAG,EAAE,aAAa,EAAE,SAAS,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,EAC/D,GAAG;IAEH,MAAM,EAAE,QAAQ,EAAE,GAAG,WAAW,EAAE,CAAA;IAClC,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,GAAG,UAAU,CAAC,aAAa,CAAC,CAAA;IAC1D,MAAM,EAAE,GAAG,aAAa,KAAK,IAAI,CAAA;IACjC,MAAM,OAAO,GAAG,aAAa,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAA;IAC3C,MAAM,MAAM,GAAG,aAAa,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAA;IACxD,MAAM,QAAQ,GAAG,GAAG,KAAK,IAAI,CAAC,CAAC,CAAC,OAAO,KAAK,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,OAAO,EAAE,MAAM,CAAC,CAAA;IACnF,kGAAkG;IAClG,uDAAuD;IACvD,MAAM,aAAa,GACjB,OAAO,IAAI,WAAW,KAAK,SAAS;QAClC,CAAC,CAAC,aAAa,CAAC,SAAS,CAAC,WAAW,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC;QACpD,CAAC,CAAC,SAAS,CAAA;IACf,MAAM,SAAS,GACb,aAAa,KAAK,SAAS;QAC3B,CAAC,GAAG,KAAK,IAAI,CAAC,CAAC,CAAC,aAAa,KAAK,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,CAAA;IAClF,MAAM,WAAW,GAAuB,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAA;IAC/D,MAAM,iBAAiB,GAAG,OAAO,SAAS,KAAK,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;IAC9F,MAAM,aAAa,GAAG,OAAO,KAAK,KAAK,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;IAC9E,MAAM,gBAAgB,GAAG,OAAO,QAAQ,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAA;IAC1F,OAAO,aAAa,CAClB,IAAI,EACJ;QACE,GAAG,IAAI;QACP,EAAE;QACF,GAAG;QACH,GAAG,CAAC,iBAAiB,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,iBAAiB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5E,GAAG,CAAC,aAAa,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAChE,6FAA6F;QAC7F,cAAc,EAAE,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS;KAC9C,EACD,gBAAgB,CACjB,CAAA;AACH,CAAC,CAAC,CAAA;AAEF;4GAC4G;AAC5G,SAAS,aAAa,CAAC,OAAe,EAAE,MAAc;IACpD,IAAI,OAAO,KAAK,MAAM;QAAE,OAAO,IAAI,CAAA;IACnC,OAAO,OAAO,CAAC,UAAU,CAAC,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,MAAM,GAAG,CAAC,CAAA;AAChE,CAAC;AAQD;;;;GAIG;AACH,MAAM,UAAU,QAAQ,CAAC,EAAE,EAAE,EAAE,OAAO,EAAiB;IACrD,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAA;IAC9B,SAAS,CAAC,GAAG,EAAE;QACb,QAAQ,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,EAAE,CAAC,CAAA;IAC7C,CAAC,EAAE,CAAC,QAAQ,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC,CAAA;IAC3B,OAAO,IAAI,CAAA;AACb,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/web-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.2",
|
|
4
4
|
"description": "React render adapter for @nifrajs/web - SSR + hydration (Bun-native JSX, no Babel).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"full-stack"
|
|
15
15
|
],
|
|
16
16
|
"type": "module",
|
|
17
|
+
"nifra": {
|
|
18
|
+
"environment": "library"
|
|
19
|
+
},
|
|
17
20
|
"main": "./dist/index.js",
|
|
18
21
|
"module": "./dist/index.js",
|
|
19
22
|
"types": "./dist/index.d.ts",
|
|
@@ -58,6 +61,11 @@
|
|
|
58
61
|
"types": "./dist/i18n.d.ts",
|
|
59
62
|
"default": "./dist/i18n.js"
|
|
60
63
|
},
|
|
64
|
+
"./auth": {
|
|
65
|
+
"bun": "./src/auth.ts",
|
|
66
|
+
"types": "./dist/auth.d.ts",
|
|
67
|
+
"default": "./dist/auth.js"
|
|
68
|
+
},
|
|
61
69
|
"./image": {
|
|
62
70
|
"bun": "./src/image.ts",
|
|
63
71
|
"types": "./dist/image.d.ts",
|
|
@@ -87,10 +95,11 @@
|
|
|
87
95
|
"access": "public"
|
|
88
96
|
},
|
|
89
97
|
"dependencies": {
|
|
90
|
-
"@nifrajs/core": "
|
|
91
|
-
"@nifrajs/
|
|
92
|
-
"@nifrajs/
|
|
93
|
-
"@nifrajs/
|
|
98
|
+
"@nifrajs/core": "4.0.2",
|
|
99
|
+
"@nifrajs/authjs": "4.0.2",
|
|
100
|
+
"@nifrajs/i18n": "4.0.2",
|
|
101
|
+
"@nifrajs/image": "4.0.2",
|
|
102
|
+
"@nifrajs/web": "4.0.2"
|
|
94
103
|
},
|
|
95
104
|
"peerDependencies": {
|
|
96
105
|
"react": "^19.0.0",
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web-react/auth` - React bindings for `@nifrajs/authjs`. `<AuthSessionProvider>`
|
|
3
|
+
* seeds the subtree with a session (from a loader on the server, refreshed in the browser) and
|
|
4
|
+
* `useAuthSession()` reads it; `signIn`/`signOut` delegate to the shared client. Imports only
|
|
5
|
+
* `react` + `@nifrajs/authjs/client`; no JSX (the package builds with plain `tsc`).
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
type AuthClient,
|
|
9
|
+
createAuthClient,
|
|
10
|
+
type Session,
|
|
11
|
+
type SignInOptions,
|
|
12
|
+
type SignOutOptions,
|
|
13
|
+
} from "@nifrajs/authjs/client"
|
|
14
|
+
import {
|
|
15
|
+
createContext,
|
|
16
|
+
createElement,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
useContext,
|
|
19
|
+
useEffect,
|
|
20
|
+
useMemo,
|
|
21
|
+
useState,
|
|
22
|
+
} from "react"
|
|
23
|
+
|
|
24
|
+
export type { Session }
|
|
25
|
+
export type AuthStatus = "loading" | "authenticated" | "unauthenticated"
|
|
26
|
+
|
|
27
|
+
export interface AuthSession {
|
|
28
|
+
readonly status: AuthStatus
|
|
29
|
+
readonly session: Session | null
|
|
30
|
+
/** Re-read the session from the server (after an out-of-band change, for example). */
|
|
31
|
+
readonly refresh: () => Promise<void>
|
|
32
|
+
readonly signIn: (providerId: string, options?: SignInOptions) => Promise<void>
|
|
33
|
+
readonly signOut: (options?: SignOutOptions) => Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const AuthSessionContext = createContext<AuthSession | null>(null)
|
|
37
|
+
|
|
38
|
+
export interface AuthSessionProviderProps {
|
|
39
|
+
/** Shared client (base path, custom fetch). Default `createAuthClient()`. */
|
|
40
|
+
readonly client?: AuthClient
|
|
41
|
+
/** SSR seed from a loader (`await getSession(request, config)`). `undefined` (default) fetches
|
|
42
|
+
* on mount instead - the subtree renders `loading` until it resolves. */
|
|
43
|
+
readonly initialSession?: Session | null
|
|
44
|
+
readonly children?: ReactNode
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function seededStatus(seed: Session | null | undefined): AuthStatus {
|
|
48
|
+
return seed === undefined ? "loading" : seed === null ? "unauthenticated" : "authenticated"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Provide the Auth.js session to the subtree. Memoized on client + seed; refresh re-reads. A new
|
|
52
|
+
* `initialSession` (a loader re-run on navigation) replaces the session, as a remount would. */
|
|
53
|
+
export function AuthSessionProvider(props: AuthSessionProviderProps): ReactNode {
|
|
54
|
+
const client = useMemo(() => props.client ?? createAuthClient(), [props.client])
|
|
55
|
+
const [seed, setSeed] = useState(props.initialSession)
|
|
56
|
+
const [status, setStatus] = useState<AuthStatus>(seededStatus(props.initialSession))
|
|
57
|
+
const [session, setSession] = useState<Session | null>(props.initialSession ?? null)
|
|
58
|
+
if (seed !== props.initialSession) {
|
|
59
|
+
// A render-phase update: React re-runs this component before any child sees the old session.
|
|
60
|
+
setSeed(props.initialSession)
|
|
61
|
+
setStatus(seededStatus(props.initialSession))
|
|
62
|
+
setSession(props.initialSession ?? null)
|
|
63
|
+
}
|
|
64
|
+
const refresh = useMemo(() => {
|
|
65
|
+
return async (): Promise<void> => {
|
|
66
|
+
try {
|
|
67
|
+
const next = await client.getSession()
|
|
68
|
+
setSession(next)
|
|
69
|
+
setStatus(next === null ? "unauthenticated" : "authenticated")
|
|
70
|
+
} catch {
|
|
71
|
+
// A failed session read must fail closed as anonymous; leaving "loading" can strand the
|
|
72
|
+
// subtree in an auth-pending state and make protected UI appear available by omission.
|
|
73
|
+
setSession(null)
|
|
74
|
+
setStatus("unauthenticated")
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}, [client])
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
let live = true
|
|
80
|
+
if (props.initialSession === undefined) {
|
|
81
|
+
void client.getSession().then(
|
|
82
|
+
(next) => {
|
|
83
|
+
if (!live) return
|
|
84
|
+
setSession(next)
|
|
85
|
+
setStatus(next === null ? "unauthenticated" : "authenticated")
|
|
86
|
+
},
|
|
87
|
+
() => {
|
|
88
|
+
// Treat an unavailable session endpoint as anonymous rather than keeping auth state
|
|
89
|
+
// pending indefinitely or exposing stale authenticated UI.
|
|
90
|
+
if (!live) return
|
|
91
|
+
setSession(null)
|
|
92
|
+
setStatus("unauthenticated")
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
return () => {
|
|
97
|
+
live = false
|
|
98
|
+
}
|
|
99
|
+
}, [client, props.initialSession])
|
|
100
|
+
const value = useMemo<AuthSession>(
|
|
101
|
+
() => ({
|
|
102
|
+
status,
|
|
103
|
+
session,
|
|
104
|
+
refresh,
|
|
105
|
+
signIn: (providerId, options) => client.signIn(providerId, options),
|
|
106
|
+
signOut: async (options) => {
|
|
107
|
+
await client.signOut(options)
|
|
108
|
+
// The server ended the session: the subtree must not go on showing it, whether a redirect is
|
|
109
|
+
// pending or `redirect: false` keeps the page.
|
|
110
|
+
setSession(null)
|
|
111
|
+
setStatus("unauthenticated")
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
[status, session, refresh, client],
|
|
115
|
+
)
|
|
116
|
+
return createElement(AuthSessionContext.Provider, { value }, props.children)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Read the Auth.js session (`{ status, session }`). Throws if no `<AuthSessionProvider>` is above. */
|
|
120
|
+
export function useAuthSession(): AuthSession {
|
|
121
|
+
const session = useContext(AuthSessionContext)
|
|
122
|
+
if (session === null) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"[nifra/web-react] useAuthSession() must be used within an <AuthSessionProvider>",
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
return session
|
|
128
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import type { MountRouterOptions, RenderProps } from "@nifrajs/web"
|
|
2
|
-
// `/client`, not the root: the root's graph carries the server, and Vite's dev server evaluates it
|
|
3
|
-
// instead of tree-shaking it - which broke hydration before the browser ran a line of app code.
|
|
4
|
-
import { searchOfChain } from "@nifrajs/web/client"
|
|
5
2
|
/**
|
|
6
3
|
* @nifrajs/web-react/client - React client runtime. `hydrate` hydrates a single SSR'd route;
|
|
7
4
|
* `mountRouter` hydrates a stateful Router that subscribes to the agnostic store (via
|
|
@@ -13,6 +10,7 @@ import { createElement, type FunctionComponent, useSyncExternalStore } from "rea
|
|
|
13
10
|
import { hydrateRoot } from "react-dom/client"
|
|
14
11
|
import { compose } from "./compose.ts"
|
|
15
12
|
import { setMountedRouter } from "./fetcher.ts"
|
|
13
|
+
import { routeProps } from "./route-props.ts"
|
|
16
14
|
|
|
17
15
|
// The `_error` boundary chain element - defined in its own (react-dom-free) module, re-exported here so
|
|
18
16
|
// nifra's client codegen resolves it from `@nifrajs/web-react/client` alongside `mountRouter`.
|
|
@@ -58,29 +56,16 @@ export const hydrationAssuranceHook = Object.freeze({
|
|
|
58
56
|
* SSR markup on hydration.
|
|
59
57
|
*/
|
|
60
58
|
export function mountRouter(options: MountRouterOptions): void {
|
|
61
|
-
const { router, routes, searchSchemas, container } = options
|
|
59
|
+
const { router, routes, searchSchemas, matchChains, container } = options
|
|
62
60
|
setMountedRouter(router) // expose it to useFetcher/useFetchers (same page, client-only)
|
|
61
|
+
// What the server rendered. React hydrates against THIS, whatever the store says by the time it gets
|
|
62
|
+
// to the component - hydration is scheduled, and a store that moved on first (a client loader that
|
|
63
|
+
// answered, a held `ssr = false` page released) would otherwise hydrate a tree the markup never had.
|
|
64
|
+
// React then re-renders with the live snapshot on its own.
|
|
65
|
+
const hydrated = router.snapshot()
|
|
63
66
|
const Router: FunctionComponent = () => {
|
|
64
|
-
const state = useSyncExternalStore(router.subscribe, router.snapshot,
|
|
65
|
-
|
|
66
|
-
// page, merged page-wins), the SAME `searchOfChain` the server ran, so `useSearch` reads an identical
|
|
67
|
-
// value and hydrates with no drift.
|
|
68
|
-
const q = state.path.indexOf("?")
|
|
69
|
-
const rawSearch = q === -1 ? "" : state.path.slice(q)
|
|
70
|
-
return compose(routes[state.routeId] ?? [], {
|
|
71
|
-
data: state.data,
|
|
72
|
-
actionData: state.actionData,
|
|
73
|
-
pending: state.pending,
|
|
74
|
-
...(state.pendingPath !== undefined ? { pendingPath: state.pendingPath } : {}),
|
|
75
|
-
// `params`/`path` feed the routing hooks (useParams/useLocation) via compose's RouterContext -
|
|
76
|
-
// sourced from router state here, matching the SSR render's request-derived values on hydration.
|
|
77
|
-
params: state.params,
|
|
78
|
-
path: state.path,
|
|
79
|
-
search: searchOfChain(searchSchemas?.[state.routeId] ?? [], rawSearch),
|
|
80
|
-
// The in-flight submission (for optimistic UI) - spread only when present.
|
|
81
|
-
...(state.submission ? { submission: state.submission } : {}),
|
|
82
|
-
...(state.boundaries !== undefined ? { boundaries: state.boundaries } : {}),
|
|
83
|
-
})
|
|
67
|
+
const state = useSyncExternalStore(router.subscribe, router.snapshot, () => hydrated)
|
|
68
|
+
return compose(routes[state.routeId] ?? [], routeProps(state, searchSchemas, matchChains))
|
|
84
69
|
}
|
|
85
70
|
const onRecoverableError = assuranceError()
|
|
86
71
|
hydrateRoot(
|
package/src/compose.ts
CHANGED
package/src/i18n.ts
CHANGED
|
@@ -5,28 +5,40 @@
|
|
|
5
5
|
* and the client rebuilds the same formatter from the same props (no mismatch). Imports only `react` +
|
|
6
6
|
* `@nifrajs/i18n`; no JSX (the package builds with plain `tsc`).
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
|
|
8
|
+
import {
|
|
9
|
+
createFormatter,
|
|
10
|
+
type Formatter,
|
|
11
|
+
type FormatterOptions,
|
|
12
|
+
type MessageKey,
|
|
13
|
+
type RegisteredMessages,
|
|
14
|
+
type Translation,
|
|
15
|
+
} from "@nifrajs/i18n"
|
|
16
|
+
import { type RichChunks, type RichRenderer, renderRich } from "@nifrajs/i18n/rich"
|
|
17
|
+
import { createContext, createElement, Fragment, type ReactNode, useContext, useMemo } from "react"
|
|
10
18
|
|
|
11
19
|
const I18nContext = createContext<Formatter | null>(null)
|
|
12
20
|
|
|
13
|
-
export interface I18nProviderProps {
|
|
21
|
+
export interface I18nProviderProps extends FormatterOptions {
|
|
14
22
|
readonly locale: string
|
|
15
|
-
|
|
23
|
+
/** The catalog for `locale`, checked against the registered catalog type when one is declared. */
|
|
24
|
+
readonly messages: Translation
|
|
16
25
|
readonly children?: ReactNode
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
/** Provide a {@link Formatter} (built from `locale` + `messages
|
|
20
|
-
* `
|
|
28
|
+
/** Provide a {@link Formatter} (built from `locale` + `messages`, with the optional `fallback`,
|
|
29
|
+
* `onMissing`, `timeZone` and `numberingSystem` of `createFormatter`) to the subtree. Memoized
|
|
30
|
+
* on those props, so switching locale rebuilds it and re-renders consumers; formatters are cached by
|
|
31
|
+
* catalog identity, so an inline `fallback={[en]}` still yields the same instance. */
|
|
21
32
|
export function I18nProvider(props: I18nProviderProps): ReactNode {
|
|
33
|
+
const { locale, messages, fallback, onMissing, timeZone, numberingSystem } = props
|
|
22
34
|
const formatter = useMemo(
|
|
23
|
-
() => createFormatter(
|
|
24
|
-
[
|
|
35
|
+
() => createFormatter(locale, messages, { fallback, onMissing, timeZone, numberingSystem }),
|
|
36
|
+
[locale, messages, fallback, onMissing, timeZone, numberingSystem],
|
|
25
37
|
)
|
|
26
38
|
return createElement(I18nContext.Provider, { value: formatter }, props.children)
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
/** Read the current {@link Formatter} (`{ locale, t, n, d }`). Throws if no `<I18nProvider>` is above. */
|
|
41
|
+
/** Read the current {@link Formatter} (`{ locale, t, get, n, d }`). Throws if no `<I18nProvider>` is above. */
|
|
30
42
|
export function useT(): Formatter {
|
|
31
43
|
const formatter = useContext(I18nContext)
|
|
32
44
|
if (formatter === null) {
|
|
@@ -34,3 +46,31 @@ export function useT(): Formatter {
|
|
|
34
46
|
}
|
|
35
47
|
return formatter
|
|
36
48
|
}
|
|
49
|
+
|
|
50
|
+
/** Tag handlers for {@link rich}, by tag name: each receives its tag's content as one node. */
|
|
51
|
+
export type RichTags = Readonly<Record<string, (content: ReactNode) => ReactNode>>
|
|
52
|
+
|
|
53
|
+
// Children passed as arguments, not an array, so React asks for no keys.
|
|
54
|
+
const join = (chunks: RichChunks<ReactNode>): ReactNode =>
|
|
55
|
+
chunks.length === 0
|
|
56
|
+
? null
|
|
57
|
+
: chunks.length === 1
|
|
58
|
+
? chunks[0]
|
|
59
|
+
: createElement(Fragment, null, ...chunks)
|
|
60
|
+
const REACT_RICH: RichRenderer<ReactNode> = { join, lineBreak: () => createElement("br") }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The message at `key` with its tags rendered by `tags`, as React nodes - no HTML, no
|
|
64
|
+
* `dangerouslySetInnerHTML`. `"Read the <link>terms</link>"` with
|
|
65
|
+
* `{ link: (content) => <a href="/terms">{content}</a> }` renders the link around "terms"; a tag with
|
|
66
|
+
* no handler renders its content as text, `<br/>` is a `<br>`, and interpolated values are always
|
|
67
|
+
* text. See `@nifrajs/i18n/rich`.
|
|
68
|
+
*/
|
|
69
|
+
export function rich<M extends object = RegisteredMessages>(
|
|
70
|
+
formatter: Formatter<M>,
|
|
71
|
+
key: MessageKey<M>,
|
|
72
|
+
tags?: RichTags,
|
|
73
|
+
vars?: Readonly<Record<string, unknown>>,
|
|
74
|
+
): ReactNode {
|
|
75
|
+
return renderRich(REACT_RICH, formatter, key, tags, vars)
|
|
76
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { RenderAdapter } from "@nifrajs/web"
|
|
|
6
6
|
*/
|
|
7
7
|
import { compose } from "./compose.ts"
|
|
8
8
|
import { reactDomServer } from "./react-dom-server.ts"
|
|
9
|
+
import { explainRenderError } from "./render-error.ts"
|
|
9
10
|
|
|
10
11
|
/** The React server render adapter - pass to @nifrajs/web's `renderPage`. */
|
|
11
12
|
export const reactAdapter: RenderAdapter = {
|
|
@@ -19,14 +20,25 @@ export const reactAdapter: RenderAdapter = {
|
|
|
19
20
|
// renderPage awaits it.
|
|
20
21
|
async renderToString(chain, props) {
|
|
21
22
|
const { renderToString } = await reactDomServer()
|
|
22
|
-
|
|
23
|
+
try {
|
|
24
|
+
return renderToString(compose(chain, props))
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw explainRenderError(error)
|
|
27
|
+
}
|
|
23
28
|
},
|
|
24
|
-
async renderToStream(chain, props) {
|
|
29
|
+
async renderToStream(chain, props, options) {
|
|
25
30
|
// Resolves a Web `ReadableStream<Uint8Array>` once the shell is renderable; Suspense
|
|
26
31
|
// boundaries stream as they resolve. No `bootstrapModules` - nifra injects the client entry in
|
|
27
32
|
// the document tail. React's default `onError` logs to console.error (errors aren't swallowed).
|
|
33
|
+
// The nonce reaches the inline runtime React streams to reveal each resolved boundary.
|
|
28
34
|
const { renderToReadableStream } = await reactDomServer()
|
|
29
|
-
|
|
35
|
+
try {
|
|
36
|
+
return await (options?.nonce === undefined
|
|
37
|
+
? renderToReadableStream(compose(chain, props))
|
|
38
|
+
: renderToReadableStream(compose(chain, props), { nonce: options.nonce }))
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw explainRenderError(error)
|
|
41
|
+
}
|
|
30
42
|
},
|
|
31
43
|
// React reconciles against the existing DOM on hydrate, so no per-document bootstrap
|
|
32
44
|
// script is needed (contrast Solid's generateHydrationScript) - the seam allows both.
|
package/src/react-dom-server.ts
CHANGED
|
@@ -29,7 +29,10 @@ import type { ReactNode } from "react"
|
|
|
29
29
|
* resolves to an absolute path string) stays strict - no `any` crosses the boundary. */
|
|
30
30
|
export interface ReactDomServer {
|
|
31
31
|
renderToString(node: ReactNode): string
|
|
32
|
-
renderToReadableStream(
|
|
32
|
+
renderToReadableStream(
|
|
33
|
+
node: ReactNode,
|
|
34
|
+
options?: { readonly nonce?: string },
|
|
35
|
+
): Promise<ReadableStream<Uint8Array>>
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
interface RuntimeProcess {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** A hook read a null dispatcher: the component's React is not the one the renderer set up. JSC and V8
|
|
2
|
+
* word the null dereference differently; the property read is always a hook. */
|
|
3
|
+
const NO_DISPATCHER =
|
|
4
|
+
/ReactSharedInternals\.H\.|resolveDispatcher\(\)\.|Cannot read properties of null \(reading 'use[A-Za-z]*'\)/
|
|
5
|
+
|
|
6
|
+
/** Name the cause of a null-dispatcher crash. The app-root check in ./react-dom-server catches a
|
|
7
|
+
* renderer on the wrong copy; this covers a component that brings its own (a linked package's). */
|
|
8
|
+
export function explainRenderError(error: unknown): unknown {
|
|
9
|
+
if (!(error instanceof TypeError) || !NO_DISPATCHER.test(error.message)) return error
|
|
10
|
+
return new Error(
|
|
11
|
+
"[nifra/web-react] a component called a React hook with no dispatcher, so two copies of React " +
|
|
12
|
+
"reached SSR: the one react-dom renders with, and another the component imports (often a linked " +
|
|
13
|
+
"package's own node_modules). Dedupe react to one physical copy: `nifra check` names the " +
|
|
14
|
+
"importer of each, and the package.json `nifra.singleCopy` declaration redirects them. " +
|
|
15
|
+
`See https://nifra.dev/docs/troubleshooting. (${error.message})`,
|
|
16
|
+
{ cause: error },
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MountRouterOptions, RenderProps, RouterState } from "@nifrajs/web"
|
|
2
|
+
// `/client`, not the root: the root's graph carries the server, and Vite's dev server evaluates it
|
|
3
|
+
// instead of tree-shaking it - which broke hydration before the browser ran a line of app code.
|
|
4
|
+
import { searchOfChain } from "@nifrajs/web/client"
|
|
5
|
+
|
|
6
|
+
/** The client half of the server's `RenderProps`, so the first render matches the SSR markup. */
|
|
7
|
+
export function routeProps(
|
|
8
|
+
state: RouterState,
|
|
9
|
+
searchSchemas: MountRouterOptions["searchSchemas"],
|
|
10
|
+
matchChains?: MountRouterOptions["matchChains"],
|
|
11
|
+
): RenderProps {
|
|
12
|
+
const matchChain = matchChains?.[state.routeId]
|
|
13
|
+
// Derive this route's typed `search` from the URL query + the route's schema CHAIN (layout schemas +
|
|
14
|
+
// page, merged page-wins), the SAME `searchOfChain` the server ran, so `useSearch` reads an identical
|
|
15
|
+
// value and hydrates with no drift.
|
|
16
|
+
const q = state.path.indexOf("?")
|
|
17
|
+
const rawSearch = q === -1 ? "" : state.path.slice(q)
|
|
18
|
+
return {
|
|
19
|
+
data: state.data,
|
|
20
|
+
// Each layout's own loader data. Without it a layout renders `data: null` on the client while the
|
|
21
|
+
// server rendered it with its data - a hydration mismatch on the first paint.
|
|
22
|
+
...(state.layoutData !== undefined ? { layoutData: state.layoutData } : {}),
|
|
23
|
+
actionData: state.actionData,
|
|
24
|
+
pending: state.pending,
|
|
25
|
+
...(state.pendingPath !== undefined ? { pendingPath: state.pendingPath } : {}),
|
|
26
|
+
// `params`/`path` feed the routing hooks (useParams/useLocation) via compose's RouterContext -
|
|
27
|
+
// sourced from router state here, matching the SSR render's request-derived values on hydration.
|
|
28
|
+
params: state.params,
|
|
29
|
+
path: state.path,
|
|
30
|
+
search: searchOfChain(searchSchemas?.[state.routeId] ?? [], rawSearch),
|
|
31
|
+
// The in-flight submission (for optimistic UI) - spread only when present.
|
|
32
|
+
...(state.submission ? { submission: state.submission } : {}),
|
|
33
|
+
...(state.boundaries !== undefined ? { boundaries: state.boundaries } : {}),
|
|
34
|
+
// The chain `useMatches` reports - the server passed the same one to the SSR render.
|
|
35
|
+
...(matchChain !== undefined ? { matchChain } : {}),
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -22,6 +22,9 @@ import type {
|
|
|
22
22
|
NavigateFunction,
|
|
23
23
|
NavigateOptions,
|
|
24
24
|
NavigateTargetInput,
|
|
25
|
+
PrefetchMode,
|
|
26
|
+
RenderProps,
|
|
27
|
+
UIMatch,
|
|
25
28
|
} from "@nifrajs/web"
|
|
26
29
|
// `/client`, not the root - these are DOM values, and the root's graph carries the
|
|
27
30
|
// server, which Vite's dev server evaluates rather than tree-shakes.
|
|
@@ -31,6 +34,8 @@ import {
|
|
|
31
34
|
registerBlocker,
|
|
32
35
|
resolveNavigate,
|
|
33
36
|
} from "@nifrajs/web/client"
|
|
37
|
+
// Its own subpath, so an app that never calls `useMatches` bundles none of it.
|
|
38
|
+
import { chainMatches } from "@nifrajs/web/internal/matches-runtime"
|
|
34
39
|
import {
|
|
35
40
|
type AnchorHTMLAttributes,
|
|
36
41
|
type CSSProperties,
|
|
@@ -47,7 +52,7 @@ import {
|
|
|
47
52
|
useState,
|
|
48
53
|
} from "react"
|
|
49
54
|
|
|
50
|
-
export type { Blocker, BlockerFunction, BlockerState, NavigateFunction }
|
|
55
|
+
export type { Blocker, BlockerFunction, BlockerState, NavigateFunction, PrefetchMode, UIMatch }
|
|
51
56
|
|
|
52
57
|
/** The current route the routing hooks read. Provided by `compose` on SSR + client mount alike. */
|
|
53
58
|
export interface RouterContextValue {
|
|
@@ -66,6 +71,8 @@ export interface RouterContextValue {
|
|
|
66
71
|
/** The `pathname + search` a navigation is transitioning TO while `pending` (`undefined` when idle or
|
|
67
72
|
* during a same-route revalidation). Powers {@link NavLink}'s per-link `isPending`. */
|
|
68
73
|
readonly pendingPath?: string | undefined
|
|
74
|
+
/** The render's props, which {@link useMatches} derives the matches from. */
|
|
75
|
+
readonly renderProps?: RenderProps | undefined
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
// Frozen empty params/search so the default context value has a stable reference (no needless re-renders).
|
|
@@ -141,6 +148,26 @@ export function useSearch<
|
|
|
141
148
|
: Record<string, unknown>
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
const NO_MATCHES: readonly UIMatch[] = Object.freeze([])
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The rendered chain, outermost layout first and the page last: each module's `id`, the URL
|
|
155
|
+
* `pathname` it wraps, its `params` and loader `data`, and its `handle` export. The same list on the
|
|
156
|
+
* server and in the browser, so a layout can render breadcrumbs or read a flag the page exports:
|
|
157
|
+
*
|
|
158
|
+
* ```tsx
|
|
159
|
+
* export const handle = { crumb: "Settings" } // in a page or a layout
|
|
160
|
+
* // in a layout above it:
|
|
161
|
+
* const crumbs = useMatches().flatMap((m) => (m.handle as { crumb?: string } | undefined)?.crumb ?? [])
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* Empty outside a nifra route tree.
|
|
165
|
+
*/
|
|
166
|
+
export function useMatches(): readonly UIMatch[] {
|
|
167
|
+
const props = useContext(RouterContext).renderProps
|
|
168
|
+
return useMemo(() => (props === undefined ? NO_MATCHES : chainMatches(props)), [props])
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
/** The parsed current location. `hash` is always `""` - the fragment is client-only and never reaches
|
|
145
172
|
* the router state / server, so exposing a live hash would hydration-mismatch; read `window.location.hash`
|
|
146
173
|
* directly (in an effect) if you truly need it. */
|
|
@@ -307,12 +334,16 @@ function isPlainLeftClick(event: MouseEvent, target: string | undefined): boolea
|
|
|
307
334
|
return target === undefined || target === "" || target === "_self"
|
|
308
335
|
}
|
|
309
336
|
|
|
310
|
-
/** {@link Link} props: every `<a>` attribute except `href` (set from `to`), plus `to
|
|
337
|
+
/** {@link Link} props: every `<a>` attribute except `href` (set from `to`), plus `to`, `replace` and
|
|
338
|
+
* `prefetch`. */
|
|
311
339
|
export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
|
|
312
340
|
/** Same-origin destination path (e.g. `/users/7?tab=posts`). Rendered as the `<a href>`. */
|
|
313
341
|
readonly to: string
|
|
314
342
|
/** Replace the current history entry instead of pushing. */
|
|
315
343
|
readonly replace?: boolean
|
|
344
|
+
/** When the link warms its route ahead of a click - rendered as `data-nifra-prefetch`. Unset, the
|
|
345
|
+
* nearest ancestor's `data-nifra-prefetch` decides, else `intent` (hover or focus). */
|
|
346
|
+
readonly prefetch?: PrefetchMode
|
|
316
347
|
}
|
|
317
348
|
|
|
318
349
|
/**
|
|
@@ -322,7 +353,7 @@ export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
|
322
353
|
* document-level click handler sees `defaultPrevented` and stands down - exactly one navigation.
|
|
323
354
|
*/
|
|
324
355
|
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
|
|
325
|
-
{ to, replace, onClick, target, ...rest },
|
|
356
|
+
{ to, replace, prefetch, onClick, target, ...rest },
|
|
326
357
|
ref,
|
|
327
358
|
) {
|
|
328
359
|
const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
|
@@ -334,7 +365,14 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(function Link(
|
|
|
334
365
|
event.preventDefault()
|
|
335
366
|
navigate(to, { replace: replace === true })
|
|
336
367
|
}
|
|
337
|
-
return createElement("a", {
|
|
368
|
+
return createElement("a", {
|
|
369
|
+
...rest,
|
|
370
|
+
href: to,
|
|
371
|
+
target,
|
|
372
|
+
onClick: handleClick,
|
|
373
|
+
ref,
|
|
374
|
+
...(prefetch !== undefined ? { "data-nifra-prefetch": prefetch } : {}),
|
|
375
|
+
})
|
|
338
376
|
})
|
|
339
377
|
|
|
340
378
|
/** The state a {@link NavLink}'s function-form `className`/`style`/`children` receive. */
|