@nifrajs/web 1.8.0 → 1.9.1
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/client.d.ts.map +1 -1
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -6
- package/dist/index.js.map +1 -1
- package/dist/navigation.d.ts +31 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +27 -0
- package/dist/navigation.js.map +1 -0
- package/dist/query.d.ts +108 -6
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +306 -34
- package/dist/query.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +24 -3
- package/src/index.ts +70 -5
- package/src/navigation.ts +42 -0
- package/src/query.ts +474 -37
package/src/client.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* a full page load). DOM-only — never imported on the server, so the store stays SSR-safe.
|
|
6
6
|
*/
|
|
7
7
|
import type { Meta } from "./manifest.ts"
|
|
8
|
+
import { type BrowserNavigate, setBrowserNavigate } from "./navigation.ts"
|
|
8
9
|
import type { ClientRouter } from "./router.ts"
|
|
9
10
|
|
|
10
11
|
export interface InstallHistoryOptions {
|
|
@@ -80,9 +81,14 @@ export function installHistory(
|
|
|
80
81
|
for (const p of [vt.ready, vt.finished, vt.updateCallbackDone]) p.catch(() => {})
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
const go = (path: string,
|
|
84
|
+
const go = (path: string, mode: "push" | "replace"): void => {
|
|
84
85
|
const url = new URL(path, location.origin)
|
|
85
|
-
if (
|
|
86
|
+
if (mode === "replace") {
|
|
87
|
+
// Replace the current entry with the new URL. The entry we're leaving is discarded, so its scroll
|
|
88
|
+
// isn't worth saving; the new route scrolls to its fragment target, else the top.
|
|
89
|
+
history.replaceState({}, "", path)
|
|
90
|
+
pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
|
|
91
|
+
} else {
|
|
86
92
|
// Save the leaving entry's scroll, push a fresh entry (URL incl. any #hash), then scroll the new
|
|
87
93
|
// route: to the fragment target if one was given, else to the top.
|
|
88
94
|
history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
|
|
@@ -93,6 +99,20 @@ export function installHistory(
|
|
|
93
99
|
transition(() => router.navigate(url.pathname + url.search).catch(() => fallback(path)))
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
// Programmatic navigation for adapter `useNavigate` bindings, published through the DOM-free bridge
|
|
103
|
+
// (`@nifrajs/web`'s `getBrowserNavigate`). A string path pushes (or replaces); a number is a history
|
|
104
|
+
// delta (`-1` back / `1` forward), matching `history.go`. An off-route / cross-origin push still goes
|
|
105
|
+
// through `go`, whose `router.navigate` no-ops an unmatched path and whose fetch-failure falls back to
|
|
106
|
+
// a full load — same graceful degradation as a link click.
|
|
107
|
+
const navigate: BrowserNavigate = (to, navOptions) => {
|
|
108
|
+
if (typeof to === "number") {
|
|
109
|
+
history.go(to)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
go(to, navOptions?.replace === true ? "replace" : "push")
|
|
113
|
+
}
|
|
114
|
+
setBrowserNavigate(navigate)
|
|
115
|
+
|
|
96
116
|
// Resolve an event target to an in-app route path, or null (cross-origin, unknown route, or an
|
|
97
117
|
// anchor opting out via target/download/rel=external). Shared by click + hover/focus prefetch.
|
|
98
118
|
const inAppHref = (target: EventTarget | null): string | null => {
|
|
@@ -121,7 +141,7 @@ export function installHistory(
|
|
|
121
141
|
const href = inAppHref(event.target)
|
|
122
142
|
if (href === null) return
|
|
123
143
|
event.preventDefault()
|
|
124
|
-
go(href,
|
|
144
|
+
go(href, "push")
|
|
125
145
|
}
|
|
126
146
|
|
|
127
147
|
// Hover/focus an in-app link → warm its chunk + data (the store dedupes the spam).
|
|
@@ -165,6 +185,7 @@ export function installHistory(
|
|
|
165
185
|
window.addEventListener("popstate", onPopState)
|
|
166
186
|
return () => {
|
|
167
187
|
unsubscribe()
|
|
188
|
+
setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
|
|
168
189
|
document.removeEventListener("click", onClick)
|
|
169
190
|
document.removeEventListener("pointerover", onPrefetch)
|
|
170
191
|
document.removeEventListener("focusin", onPrefetch)
|
package/src/index.ts
CHANGED
|
@@ -101,14 +101,32 @@ export {
|
|
|
101
101
|
type StaticPaths,
|
|
102
102
|
type StaticRoutes,
|
|
103
103
|
} from "./manifest.ts"
|
|
104
|
+
// Navigation bridge — a DOM-free seam the browser layer (`installHistory`) populates so an adapter's
|
|
105
|
+
// `useNavigate` (a route component, importing only this agnostic entry) reaches history-aware nav.
|
|
106
|
+
export {
|
|
107
|
+
type BrowserNavigate,
|
|
108
|
+
getBrowserNavigate,
|
|
109
|
+
type NavigateOptions,
|
|
110
|
+
setBrowserNavigate,
|
|
111
|
+
} from "./navigation.ts"
|
|
104
112
|
// Keyed query-cache (agnostic) — a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
|
|
105
113
|
// consumed by the per-adapter `useQuery`/`createQuery` bindings.
|
|
106
114
|
export {
|
|
115
|
+
createMutation,
|
|
107
116
|
createQueryClient,
|
|
117
|
+
type DehydratedState,
|
|
108
118
|
hashQueryKey,
|
|
119
|
+
type InfiniteData,
|
|
120
|
+
type InfiniteQueryHandle,
|
|
121
|
+
type InfiniteQueryOptions,
|
|
122
|
+
type MutationCallbacks,
|
|
123
|
+
type MutationHandle,
|
|
124
|
+
type MutationState,
|
|
125
|
+
type MutationStatus,
|
|
109
126
|
type QueryClient,
|
|
110
127
|
type QueryClientOptions,
|
|
111
128
|
type QueryHandle,
|
|
129
|
+
type QueryOptions,
|
|
112
130
|
type QueryState,
|
|
113
131
|
type QueryStatus,
|
|
114
132
|
} from "./query.ts"
|
|
@@ -180,6 +198,14 @@ export interface RenderProps {
|
|
|
180
198
|
readonly pending?: boolean
|
|
181
199
|
/** The in-flight client submit, for optimistic UI (client-only; absent on SSR + when idle). */
|
|
182
200
|
readonly submission?: Submission
|
|
201
|
+
/** The matched route's decoded path params (`/users/:id` → `{ id: "7" }`). Threaded identically on
|
|
202
|
+
* SSR (from the request match) and on client navigation (from router state) so an adapter's
|
|
203
|
+
* `useParams` reads the same value on both, with no hydration mismatch. Absent ⇒ no params (`{}`). */
|
|
204
|
+
readonly params?: Readonly<Record<string, string>>
|
|
205
|
+
/** The current URL's `pathname + search` (no hash — the hash never reaches the server). Threaded on
|
|
206
|
+
* SSR and client alike so an adapter's `useLocation`/`useSearchParams` hydrate consistently. Absent
|
|
207
|
+
* on adapters/callers that don't supply it (treated as `""`). */
|
|
208
|
+
readonly path?: string
|
|
183
209
|
}
|
|
184
210
|
|
|
185
211
|
/**
|
|
@@ -316,6 +342,14 @@ export interface RenderPageOptions {
|
|
|
316
342
|
readonly revalidate?: number
|
|
317
343
|
/** Matched route id; written to `window.__NIFRA_ROUTE__` so the client hydrates this chain. */
|
|
318
344
|
readonly routeId?: string
|
|
345
|
+
/** The matched route's decoded path params — surfaced to the page as `params` (via {@link RenderProps})
|
|
346
|
+
* so an adapter's `useParams` is SSR-correct. Omit ⇒ `{}` (a route with no dynamic segments, or an
|
|
347
|
+
* error/404 render). */
|
|
348
|
+
readonly params?: Readonly<Record<string, string>>
|
|
349
|
+
/** The request's `pathname + search` — surfaced as `path` (via {@link RenderProps}) so an adapter's
|
|
350
|
+
* `useLocation`/`useSearchParams` render the right URL server-side and hydrate without drift. Omit ⇒
|
|
351
|
+
* `""`. */
|
|
352
|
+
readonly path?: string
|
|
319
353
|
/** HTTP status for the response (default 200; e.g. 404 for a not-found page). */
|
|
320
354
|
readonly status?: number
|
|
321
355
|
/** Document title (fallback when `head.title` is unset). */
|
|
@@ -455,7 +489,15 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
455
489
|
// dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
|
|
456
490
|
// the client's path-list channel.
|
|
457
491
|
if (revalidate !== undefined) headers[ISR_REVALIDATE_HEADER] = String(revalidate)
|
|
458
|
-
const renderProps: RenderProps = {
|
|
492
|
+
const renderProps: RenderProps = {
|
|
493
|
+
data: forComponent,
|
|
494
|
+
actionData: actionSplit?.forComponent,
|
|
495
|
+
// `params`/`path` thread the matched route + URL to the adapter's `useParams`/`useLocation` so they
|
|
496
|
+
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) — an adapter with no
|
|
497
|
+
// router bindings simply never reads them.
|
|
498
|
+
...(options.params !== undefined ? { params: options.params } : {}),
|
|
499
|
+
...(options.path !== undefined ? { path: options.path } : {}),
|
|
500
|
+
}
|
|
459
501
|
|
|
460
502
|
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
461
503
|
// whole document in one pass. Progressive streaming only benefits pages with deferred boundaries
|
|
@@ -1126,6 +1168,19 @@ export function createWebApp<Env = unknown>(
|
|
|
1126
1168
|
}
|
|
1127
1169
|
}
|
|
1128
1170
|
|
|
1171
|
+
// The request's `pathname + search` — threaded into `renderPage` so an adapter's `useLocation`/
|
|
1172
|
+
// `useSearchParams` render the current URL server-side and hydrate against the client's initial state
|
|
1173
|
+
// (which the client entry seeds from `location.pathname + location.search`). No hash: it never reaches
|
|
1174
|
+
// the server. A malformed `req.url` degrades to "/" rather than throwing during render.
|
|
1175
|
+
const pathOf = (req: Request): string => {
|
|
1176
|
+
try {
|
|
1177
|
+
const u = new URL(req.url)
|
|
1178
|
+
return u.pathname + u.search
|
|
1179
|
+
} catch {
|
|
1180
|
+
return "/"
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1129
1184
|
/**
|
|
1130
1185
|
* Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
|
|
1131
1186
|
* half of error UI — works on every adapter, no client takeover). The boundary renders in place of
|
|
@@ -1166,7 +1221,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1166
1221
|
// The 404 response — the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
|
|
1167
1222
|
// catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
|
|
1168
1223
|
// that opted out of on-demand SSR).
|
|
1169
|
-
const renderNotFound = async (): Promise<Response | RenderedPage> => {
|
|
1224
|
+
const renderNotFound = async (path?: string): Promise<Response | RenderedPage> => {
|
|
1170
1225
|
if (manifest.notFound === undefined) {
|
|
1171
1226
|
return new Response("Not Found", {
|
|
1172
1227
|
status: 404,
|
|
@@ -1180,6 +1235,9 @@ export function createWebApp<Env = unknown>(
|
|
|
1180
1235
|
data: null,
|
|
1181
1236
|
clientEntry,
|
|
1182
1237
|
routeId: "_404",
|
|
1238
|
+
// `_404` hydrates, so its SSR `path` must equal the client's initial `location.pathname+search`
|
|
1239
|
+
// (an unmatched path yields no params → `{}`), or a `useLocation` on the 404 page would drift.
|
|
1240
|
+
...(path !== undefined ? { path } : {}),
|
|
1183
1241
|
status: 404,
|
|
1184
1242
|
...preloadOf("_404"),
|
|
1185
1243
|
...stylesOf("_404"),
|
|
@@ -1197,7 +1255,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1197
1255
|
// Covers hard navigation directly; a client soft-nav's data fetch gets the 404, throws, and the
|
|
1198
1256
|
// history layer falls back to a full-page navigation (which lands here again, as a document).
|
|
1199
1257
|
if (is404Fallback && !prerenderedSet.has(new URL(c.req.url).pathname)) {
|
|
1200
|
-
return renderNotFound()
|
|
1258
|
+
return renderNotFound(pathOf(c.req))
|
|
1201
1259
|
}
|
|
1202
1260
|
const mod = await route.load()
|
|
1203
1261
|
const draft = await draftFlag(c.req)
|
|
@@ -1266,6 +1324,8 @@ export function createWebApp<Env = unknown>(
|
|
|
1266
1324
|
head,
|
|
1267
1325
|
clientEntry,
|
|
1268
1326
|
routeId: route.id,
|
|
1327
|
+
params: c.params,
|
|
1328
|
+
path: pathOf(c.req),
|
|
1269
1329
|
hydrate: hydrateRoute,
|
|
1270
1330
|
...preloadOf(route.id),
|
|
1271
1331
|
...stylesOf(route.id),
|
|
@@ -1360,6 +1420,8 @@ export function createWebApp<Env = unknown>(
|
|
|
1360
1420
|
head,
|
|
1361
1421
|
clientEntry,
|
|
1362
1422
|
routeId: route.id,
|
|
1423
|
+
params: c.params,
|
|
1424
|
+
path: pathOf(c.req),
|
|
1363
1425
|
hydrate: hydrateRoute,
|
|
1364
1426
|
...preloadOf(route.id),
|
|
1365
1427
|
...stylesOf(route.id),
|
|
@@ -1385,7 +1447,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1385
1447
|
})
|
|
1386
1448
|
|
|
1387
1449
|
// Wildcard catch-all: unmatched paths render `_404` (404), or a plain text 404 if absent.
|
|
1388
|
-
app.register("GET", "/*", undefined, () => renderNotFound())
|
|
1450
|
+
app.register("GET", "/*", undefined, (c: RouteContext) => renderNotFound(pathOf(c.req)))
|
|
1389
1451
|
|
|
1390
1452
|
return app
|
|
1391
1453
|
}
|
|
@@ -1498,7 +1560,10 @@ export function generateClientEntry(
|
|
|
1498
1560
|
"const initial = {",
|
|
1499
1561
|
` routeId: matched ? matched.routeId : (window.${ROUTE_GLOBAL} ?? ""),`,
|
|
1500
1562
|
" params: matched ? matched.params : {},",
|
|
1501
|
-
|
|
1563
|
+
// pathname + search (NOT just pathname): the SSR render threads `pathname+search` into
|
|
1564
|
+
// `useLocation`/`useSearchParams`, so the hydrating initial state must carry the query too or a
|
|
1565
|
+
// page reading the search string would hydrate-mismatch. The #hash is client-only (never SSR'd).
|
|
1566
|
+
" path: location.pathname + location.search,",
|
|
1502
1567
|
` data: mapDeferred(window.${DATA_GLOBAL}),`,
|
|
1503
1568
|
// actionData (only set after a form POST) is in the initial state so the binding hydrates
|
|
1504
1569
|
// consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web` navigation bridge — a tiny, DOM-free seam that lets an adapter's `useNavigate`
|
|
3
|
+
* (a route component, which must import only from the agnostic `@nifrajs/web` main entry — never the
|
|
4
|
+
* DOM-only `/client`) reach the browser's history-aware navigate, WITHOUT the route component pulling
|
|
5
|
+
* `@nifrajs/web/client` (and its `document`/`history` access) into a bundle that also renders on the
|
|
6
|
+
* server.
|
|
7
|
+
*
|
|
8
|
+
* The browser layer (`installHistory`, in `./client.ts`) populates this on setup and clears it on
|
|
9
|
+
* teardown; a framework binding reads it via {@link getBrowserNavigate}. Module-level singleton — the
|
|
10
|
+
* browser mounts exactly one app per page (the same convention the adapters' `setMountedRouter` uses).
|
|
11
|
+
* On the server (and before hydration) the getter returns `undefined`, so a binding degrades to the
|
|
12
|
+
* native `<a href>` full-page navigation — progressive enhancement, no throw.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Options for a programmatic navigation. */
|
|
16
|
+
export interface NavigateOptions {
|
|
17
|
+
/** Replace the current history entry instead of pushing a new one (like `history.replaceState`). */
|
|
18
|
+
readonly replace?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A history-aware navigate. A **string** `to` is a same-origin path (`/users/7?tab=a`) navigated to
|
|
23
|
+
* (push, or replace with `{ replace: true }`); a **number** is a history delta (`-1` back, `1`
|
|
24
|
+
* forward), matching the browser's `history.go`. Registered by `installHistory`.
|
|
25
|
+
*/
|
|
26
|
+
export type BrowserNavigate = (to: string | number, options?: NavigateOptions) => void
|
|
27
|
+
|
|
28
|
+
// The active browser navigate (set by `installHistory`, cleared on teardown). Module-scoped: one app
|
|
29
|
+
// per page; absent on the server and before hydration.
|
|
30
|
+
let browserNavigate: BrowserNavigate | undefined
|
|
31
|
+
|
|
32
|
+
/** Register (or clear, with `undefined`) the browser navigate — called by `installHistory`. Not for
|
|
33
|
+
* app use. */
|
|
34
|
+
export function setBrowserNavigate(navigate: BrowserNavigate | undefined): void {
|
|
35
|
+
browserNavigate = navigate
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The active browser navigate, or `undefined` on the server / before `installHistory` has run. A
|
|
39
|
+
* binding calls it when present and falls back to native navigation otherwise. */
|
|
40
|
+
export function getBrowserNavigate(): BrowserNavigate | undefined {
|
|
41
|
+
return browserNavigate
|
|
42
|
+
}
|