@nifrajs/web 2.13.0 → 3.1.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/dist/boundary.d.ts +158 -0
- package/dist/boundary.d.ts.map +1 -0
- package/dist/boundary.js +237 -0
- package/dist/boundary.js.map +1 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +5 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +9 -6
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +57 -31
- package/dist/build.js.map +1 -1
- package/dist/fn.d.ts +2 -2
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +2 -2
- package/dist/fn.js.map +1 -1
- package/dist/index.d.ts +30 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +179 -56
- package/dist/index.js.map +1 -1
- package/dist/internal/fetcher-runtime.d.ts +19 -0
- package/dist/internal/fetcher-runtime.d.ts.map +1 -0
- package/dist/internal/fetcher-runtime.js +26 -0
- package/dist/internal/fetcher-runtime.js.map +1 -0
- package/dist/internal/identity-policy.d.ts +51 -0
- package/dist/internal/identity-policy.d.ts.map +1 -0
- package/dist/internal/identity-policy.js +67 -0
- package/dist/internal/identity-policy.js.map +1 -0
- package/dist/internal/parity.d.ts +105 -7
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +303 -56
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/query-runtime.d.ts +8 -0
- package/dist/internal/query-runtime.d.ts.map +1 -0
- package/dist/internal/query-runtime.js +29 -0
- package/dist/internal/query-runtime.js.map +1 -0
- package/dist/islands.d.ts +2 -2
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +17 -36
- package/dist/islands.js.map +1 -1
- package/dist/manifest.d.ts +41 -6
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/render-seam.d.ts +5 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +10 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +10 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +233 -26
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts +8 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +51 -13
- package/dist/vite.js.map +1 -1
- package/package.json +13 -2
- package/src/boundary.ts +407 -0
- package/src/build-vite.ts +5 -3
- package/src/build.ts +61 -31
- package/src/fn.ts +6 -6
- package/src/index.ts +255 -54
- package/src/internal/fetcher-runtime.ts +36 -0
- package/src/internal/identity-policy.ts +94 -0
- package/src/internal/parity.ts +447 -70
- package/src/internal/query-runtime.ts +31 -0
- package/src/islands.ts +22 -41
- package/src/manifest.ts +50 -3
- package/src/render-seam.ts +6 -0
- package/src/router.ts +271 -29
- package/src/vite.ts +75 -13
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { QueryClient, QueryState } from "../query.ts"
|
|
2
|
+
import { createQueryClient } from "../query.ts"
|
|
3
|
+
|
|
4
|
+
/** Stable idle state shared by every framework query binding. */
|
|
5
|
+
export const IDLE_QUERY_STATE: QueryState<never> = Object.freeze({
|
|
6
|
+
status: "pending",
|
|
7
|
+
data: undefined,
|
|
8
|
+
error: undefined,
|
|
9
|
+
isFetching: false,
|
|
10
|
+
updatedAt: Number.NEGATIVE_INFINITY,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
let singleton: QueryClient | undefined
|
|
14
|
+
|
|
15
|
+
/** Return the client-side query singleton, or undefined during SSR. */
|
|
16
|
+
export function getQueryClientSingleton(): QueryClient | undefined {
|
|
17
|
+
// `typeof globalThis.window`, not `"window" in globalThis`: an SSR shim that assigns
|
|
18
|
+
// `globalThis.window = undefined` still satisfies the `in` check (the key exists), which would
|
|
19
|
+
// build a process-global cache on the server and leak state across requests. A real DOM window is
|
|
20
|
+
// a defined object; a shimmed-undefined one reads as `"undefined"` here and stays server-side.
|
|
21
|
+
if (typeof (globalThis as { window?: unknown }).window === "undefined") return undefined
|
|
22
|
+
if (singleton === undefined) singleton = createQueryClient({ now: () => Date.now() })
|
|
23
|
+
return singleton
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Create the refetch fallback used when a binding has no client-side query cache. */
|
|
27
|
+
export function createNoClientRefetch<T>(tag: string, operation: string): () => Promise<T> {
|
|
28
|
+
return async () => {
|
|
29
|
+
throw new Error(`${tag} ${operation} called with no query client (server?)`)
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/islands.ts
CHANGED
|
@@ -15,8 +15,13 @@
|
|
|
15
15
|
* the route's `islandScripts`.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
import {
|
|
19
|
+
type IslandStrategy,
|
|
20
|
+
MAX_MEDIA_QUERY_LENGTH,
|
|
21
|
+
scheduleTrigger,
|
|
22
|
+
} from "@nifrajs/island-trigger"
|
|
23
|
+
|
|
24
|
+
export { type IslandStrategy, MAX_MEDIA_QUERY_LENGTH, scheduleTrigger }
|
|
20
25
|
|
|
21
26
|
/** Optional teardown an enhancer returns (remove listeners/observers); run on `dispose()`. */
|
|
22
27
|
export type IslandCleanup = () => void
|
|
@@ -29,8 +34,6 @@ export type IslandCleanup = () => void
|
|
|
29
34
|
// biome-ignore lint/suspicious/noConfusingVoidType: `void` = "no cleanup returned", like React's EffectCallback - cleanup is optional.
|
|
30
35
|
export type IslandEnhancer<P = unknown> = (el: HTMLElement, props: P) => IslandCleanup | void
|
|
31
36
|
|
|
32
|
-
const NOOP = (): void => {}
|
|
33
|
-
|
|
34
37
|
/** Parse an island's inline `data-props` JSON; malformed/absent → `undefined` (never throws). */
|
|
35
38
|
function readProps(el: HTMLElement): unknown {
|
|
36
39
|
const raw = el.dataset.props
|
|
@@ -42,40 +45,6 @@ function readProps(el: HTMLElement): unknown {
|
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
/** Run `run` per the island's strategy; returns a disposer that cancels a not-yet-fired trigger. */
|
|
46
|
-
function whenStrategy(el: HTMLElement, strategy: IslandStrategy, run: () => void): () => void {
|
|
47
|
-
if (strategy === "idle") {
|
|
48
|
-
const ric = globalThis.requestIdleCallback
|
|
49
|
-
if (ric === undefined) {
|
|
50
|
-
const t = setTimeout(run, 1)
|
|
51
|
-
return () => clearTimeout(t)
|
|
52
|
-
}
|
|
53
|
-
const handle = ric(run)
|
|
54
|
-
return () => globalThis.cancelIdleCallback?.(handle)
|
|
55
|
-
}
|
|
56
|
-
if (strategy === "visible") {
|
|
57
|
-
// No IntersectionObserver (old engine / SSR smoke) → degrade to immediate, never skip the island.
|
|
58
|
-
if (typeof IntersectionObserver === "undefined") {
|
|
59
|
-
run()
|
|
60
|
-
return NOOP
|
|
61
|
-
}
|
|
62
|
-
const io = new IntersectionObserver((entries, obs) => {
|
|
63
|
-
for (const entry of entries) {
|
|
64
|
-
if (entry.isIntersecting) {
|
|
65
|
-
obs.disconnect()
|
|
66
|
-
run()
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
})
|
|
71
|
-
io.observe(el)
|
|
72
|
-
return () => io.disconnect()
|
|
73
|
-
}
|
|
74
|
-
// "load" (and any unknown value) → run immediately on mount.
|
|
75
|
-
run()
|
|
76
|
-
return NOOP
|
|
77
|
-
}
|
|
78
|
-
|
|
79
48
|
/**
|
|
80
49
|
* Find every `<nifra-island data-id>` under `root` (default `document`) and enhance each with the
|
|
81
50
|
* matching enhancer, honoring its `data-strategy`. An island whose `id` has no enhancer is left as
|
|
@@ -96,8 +65,20 @@ export function mountIslands(
|
|
|
96
65
|
if (enhancer === undefined) continue // no enhancer for this id → leave it as inert SSR markup
|
|
97
66
|
const props = readProps(el)
|
|
98
67
|
const strategyAttr = el.dataset.strategy
|
|
99
|
-
const strategy: IslandStrategy =
|
|
100
|
-
strategyAttr === "idle" || strategyAttr === "visible"
|
|
68
|
+
const strategy: IslandStrategy | undefined =
|
|
69
|
+
strategyAttr === "idle" || strategyAttr === "visible" || strategyAttr === "load"
|
|
70
|
+
? strategyAttr
|
|
71
|
+
: strategyAttr === "media" &&
|
|
72
|
+
el.dataset.media !== undefined &&
|
|
73
|
+
el.dataset.media.length > 0 &&
|
|
74
|
+
el.dataset.media.length <= MAX_MEDIA_QUERY_LENGTH
|
|
75
|
+
? { media: el.dataset.media }
|
|
76
|
+
: strategyAttr === undefined
|
|
77
|
+
? "load"
|
|
78
|
+
: undefined
|
|
79
|
+
// Invalid media markers remain inert rather than silently becoming eager. Their SSR content stays
|
|
80
|
+
// usable, and untrusted HTML cannot turn an oversized query into an unexpected trigger.
|
|
81
|
+
if (strategy === undefined) continue
|
|
101
82
|
const run = (): void => {
|
|
102
83
|
try {
|
|
103
84
|
const cleanup = enhancer(el, props)
|
|
@@ -107,7 +88,7 @@ export function mountIslands(
|
|
|
107
88
|
console.error(`[nifra/islands] enhancer "${id}" failed:`, err)
|
|
108
89
|
}
|
|
109
90
|
}
|
|
110
|
-
disposers.push(
|
|
91
|
+
disposers.push(scheduleTrigger(strategy, run, { target: el }))
|
|
111
92
|
}
|
|
112
93
|
return () => {
|
|
113
94
|
for (const dispose of disposers) {
|
package/src/manifest.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* portable (no fs, no DOM) and fully unit-testable. Edge deploys pre-build the manifest.
|
|
6
6
|
*/
|
|
7
7
|
import type { StandardSchemaV1 } from "@nifrajs/core/server"
|
|
8
|
+
import type { BoundaryDescriptor, BoundaryRegistration } from "./boundary.ts"
|
|
8
9
|
|
|
9
10
|
/** Context passed to a route `loader`. The `api` + `env` are injected by `createWebApp` and typed
|
|
10
11
|
* per-route via `@nifrajs/client`'s `LoaderArgs<Api, Env>` (here they are opaque to the agnostic core). */
|
|
@@ -30,11 +31,51 @@ export type Loader = (ctx: LoaderContext) => unknown | Promise<unknown>
|
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* A route's optional mutation, run on POST. Shares the loader context (params/request/api);
|
|
33
|
-
* read the form/JSON body off `request`. Returns either a
|
|
34
|
-
* passed straight through) or data,
|
|
34
|
+
* read the form/JSON body off `request`. Returns either a control-flow value (a `redirect()`, a
|
|
35
|
+
* `status(...)` render, or a hand-rolled `Response` - all passed straight through) or data,
|
|
36
|
+
* surfaced to the page component as `actionData`.
|
|
35
37
|
*/
|
|
36
38
|
export type Action = (ctx: LoaderContext) => unknown | Promise<unknown>
|
|
37
39
|
|
|
40
|
+
/** The body a client action may prepare for the server action. The server must validate it again. */
|
|
41
|
+
export type ClientRequestBody = NonNullable<RequestInit["body"]>
|
|
42
|
+
|
|
43
|
+
/** Safe, client-visible context for a client loader. It intentionally exposes no Request or headers. */
|
|
44
|
+
export interface ClientLoaderArgs {
|
|
45
|
+
readonly url: string
|
|
46
|
+
readonly params: Readonly<Record<string, string>>
|
|
47
|
+
readonly signal: AbortSignal
|
|
48
|
+
/** Lazily obtains the server loader result; repeated calls share one per-navigation request. */
|
|
49
|
+
readonly serverLoader: () => Promise<unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A client-only post-hydration data loader. Its return value replaces the route's rendered data. */
|
|
53
|
+
export type ClientLoader = (args: ClientLoaderArgs) => unknown | Promise<unknown>
|
|
54
|
+
|
|
55
|
+
/** Safe, client-visible context for a client action. No secrets or raw request headers cross this seam. */
|
|
56
|
+
export interface ClientActionArgs extends ClientLoaderArgs {
|
|
57
|
+
readonly body: ClientRequestBody
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Client action preparation. `body` is sent as untrusted input; `optimisticData` is never sent. */
|
|
61
|
+
export interface ClientActionResult {
|
|
62
|
+
readonly body?: ClientRequestBody
|
|
63
|
+
readonly optimisticData?: unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A client-only action wrapper; it never replaces the server action. */
|
|
67
|
+
export type ClientAction = (
|
|
68
|
+
args: ClientActionArgs,
|
|
69
|
+
) => ClientActionResult | void | Promise<ClientActionResult | void>
|
|
70
|
+
|
|
71
|
+
/** Client hooks populated by the generated route entry after a route chunk loads. */
|
|
72
|
+
export interface ClientRouteHooks {
|
|
73
|
+
readonly clientLoader?: ClientLoader
|
|
74
|
+
readonly clientAction?: ClientAction
|
|
75
|
+
/** Neutral boundary descriptors needed for soft-navigation interception; no server loader crosses. */
|
|
76
|
+
readonly boundaries?: readonly BoundaryDescriptor[]
|
|
77
|
+
}
|
|
78
|
+
|
|
38
79
|
/**
|
|
39
80
|
* One `<link>` tag's attributes for a route/layout's `meta.link`. The common HTML `<link>` attributes
|
|
40
81
|
* are spelled out and **optional** so a typed partial like `{ rel, href, hreflang }` is assignable -
|
|
@@ -191,6 +232,12 @@ export interface RouteModule {
|
|
|
191
232
|
readonly default: unknown
|
|
192
233
|
readonly loader?: Loader
|
|
193
234
|
readonly action?: Action
|
|
235
|
+
/** Optional client-only loader. It runs after hydration and on client navigations. */
|
|
236
|
+
readonly clientLoader?: ClientLoader
|
|
237
|
+
/** Optional client-only submit wrapper. The server action remains mandatory for mutations. */
|
|
238
|
+
readonly clientAction?: ClientAction
|
|
239
|
+
/** Named async boundaries owned by this route. The adapter renders their neutral state seam. */
|
|
240
|
+
readonly boundaries?: readonly BoundaryRegistration[]
|
|
194
241
|
/** A Standard Schema validating this route's URL search params. When present, `ctx.search` is parsed +
|
|
195
242
|
* validated against it (failing closed to the schema's defaults on invalid input); type it into the
|
|
196
243
|
* loader with `LoaderArgs<Api, Env, typeof searchSchema>`. */
|
|
@@ -249,7 +296,7 @@ export interface RouteModule {
|
|
|
249
296
|
* overrides an inner layout, which overrides an outer one). See `mergeHeads` in `@nifrajs/web`. */
|
|
250
297
|
export interface LayoutEntry {
|
|
251
298
|
readonly file: string
|
|
252
|
-
readonly load: () => Promise<
|
|
299
|
+
readonly load: () => Promise<RouteModule>
|
|
253
300
|
}
|
|
254
301
|
|
|
255
302
|
/** One matched route: pattern, nested layout ids (outermost → innermost), source file, loader. */
|
package/src/render-seam.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* without importing the document renderer through the root module. The singleton is stored on
|
|
7
7
|
* globalThis because the Bun and Vite dev pipelines can evaluate two copies in one process.
|
|
8
8
|
*/
|
|
9
|
+
|
|
10
|
+
import type { BoundaryStates } from "./boundary.ts"
|
|
9
11
|
import type { Submission } from "./router.ts"
|
|
10
12
|
|
|
11
13
|
/** The data handed to a route component. */
|
|
@@ -26,6 +28,8 @@ export interface RenderProps {
|
|
|
26
28
|
readonly path?: string
|
|
27
29
|
/** The route's typed, validated search params. */
|
|
28
30
|
readonly search?: Record<string, unknown>
|
|
31
|
+
/** Neutral named-boundary states; adapters choose how each boundary's `render` UI is mounted. */
|
|
32
|
+
readonly boundaries?: BoundaryStates
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
/**
|
|
@@ -72,5 +76,7 @@ export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
|
|
|
72
76
|
/** Global the server serializes an action's data return into (absent on GETs); the client reads it
|
|
73
77
|
* so hydration after a native form POST matches the server-rendered markup. */
|
|
74
78
|
export const ACTION_GLOBAL = "__NIFRA_ACTION__"
|
|
79
|
+
/** Dynamic-boundary states for hydration; absent when a route declares no boundaries. */
|
|
80
|
+
export const BOUNDARY_GLOBAL = "__NIFRA_BOUNDARIES__"
|
|
75
81
|
/** Marker attribute used to find a non-default hydration container. */
|
|
76
82
|
export const ROOT_ATTRIBUTE = "data-nifra-root"
|