@nifrajs/web 2.14.1 → 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.
Files changed (62) hide show
  1. package/dist/boundary.d.ts +158 -0
  2. package/dist/boundary.d.ts.map +1 -0
  3. package/dist/boundary.js +237 -0
  4. package/dist/boundary.js.map +1 -0
  5. package/dist/build-vite.d.ts.map +1 -1
  6. package/dist/build-vite.js +5 -3
  7. package/dist/build-vite.js.map +1 -1
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +31 -13
  10. package/dist/build.js.map +1 -1
  11. package/dist/index.d.ts +30 -9
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +163 -51
  14. package/dist/index.js.map +1 -1
  15. package/dist/internal/fetcher-runtime.d.ts +19 -0
  16. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  17. package/dist/internal/fetcher-runtime.js +26 -0
  18. package/dist/internal/fetcher-runtime.js.map +1 -0
  19. package/dist/internal/identity-policy.d.ts +51 -0
  20. package/dist/internal/identity-policy.d.ts.map +1 -0
  21. package/dist/internal/identity-policy.js +67 -0
  22. package/dist/internal/identity-policy.js.map +1 -0
  23. package/dist/internal/parity.d.ts +97 -8
  24. package/dist/internal/parity.d.ts.map +1 -1
  25. package/dist/internal/parity.js +281 -47
  26. package/dist/internal/parity.js.map +1 -1
  27. package/dist/internal/query-runtime.d.ts +8 -0
  28. package/dist/internal/query-runtime.d.ts.map +1 -0
  29. package/dist/internal/query-runtime.js +29 -0
  30. package/dist/internal/query-runtime.js.map +1 -0
  31. package/dist/islands.d.ts +2 -2
  32. package/dist/islands.d.ts.map +1 -1
  33. package/dist/islands.js +17 -36
  34. package/dist/islands.js.map +1 -1
  35. package/dist/manifest.d.ts +41 -6
  36. package/dist/manifest.d.ts.map +1 -1
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/render-seam.d.ts +5 -0
  39. package/dist/render-seam.d.ts.map +1 -1
  40. package/dist/render-seam.js +10 -0
  41. package/dist/render-seam.js.map +1 -1
  42. package/dist/router.d.ts +10 -0
  43. package/dist/router.d.ts.map +1 -1
  44. package/dist/router.js +233 -26
  45. package/dist/router.js.map +1 -1
  46. package/dist/vite.d.ts.map +1 -1
  47. package/dist/vite.js +8 -6
  48. package/dist/vite.js.map +1 -1
  49. package/package.json +13 -2
  50. package/src/boundary.ts +407 -0
  51. package/src/build-vite.ts +5 -3
  52. package/src/build.ts +36 -14
  53. package/src/index.ts +235 -49
  54. package/src/internal/fetcher-runtime.ts +36 -0
  55. package/src/internal/identity-policy.ts +94 -0
  56. package/src/internal/parity.ts +409 -57
  57. package/src/internal/query-runtime.ts +31 -0
  58. package/src/islands.ts +22 -41
  59. package/src/manifest.ts +50 -3
  60. package/src/render-seam.ts +6 -0
  61. package/src/router.ts +271 -29
  62. package/src/vite.ts +8 -6
@@ -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
- /** When an island's enhancer runs. Default `load`. */
19
- export type IslandStrategy = "load" | "idle" | "visible"
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" ? strategyAttr : "load"
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(whenStrategy(el, strategy, run))
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 `Response` (e.g. a redirect -
34
- * passed straight through) or data, surfaced to the page component as `actionData`.
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<{ default: unknown; meta?: MetaInput }>
299
+ readonly load: () => Promise<RouteModule>
253
300
  }
254
301
 
255
302
  /** One matched route: pattern, nested layout ids (outermost → innermost), source file, loader. */
@@ -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"