@nifrajs/web 1.7.0 → 1.9.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/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 = { data: forComponent, actionData: actionSplit?.forComponent }
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
- " path: location.pathname,",
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
+ }