@zerotal/inertia 1.0.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.
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Inertia v3 prop wrappers.
3
+ *
4
+ * A page prop can be a plain value, a function (lazy — evaluated only when included), or one of the
5
+ * wrapper classes below. The {@link resolveProps} pipeline inspects these to decide whether a prop
6
+ * is included on a given visit, when its value is evaluated, and how the client should merge it.
7
+ *
8
+ * Optional/Always/Defer/Merge prop wrappers, adapted to TypeScript.
9
+ */
10
+
11
+ /** A prop value: a concrete value, or a (possibly async) factory evaluated on demand. */
12
+ export type PropFactory = () => unknown | Promise<unknown>;
13
+
14
+ /** Resolved merge configuration contributed by a mergeable prop. */
15
+ export interface MergeConfig {
16
+ deep: boolean;
17
+ /** Nested paths to append (relative to the prop key). Empty = append at root. */
18
+ appendPaths: string[];
19
+ /** Nested paths to prepend (relative to the prop key). */
20
+ prependPaths: string[];
21
+ /** Paths (relative to the prop key) whose last segment is the match field. */
22
+ matchOn: string[];
23
+ /** Prepend at the root level instead of appending. */
24
+ prependRoot: boolean;
25
+ }
26
+
27
+ /**
28
+ * Base class for all prop wrappers. Carries the shared `once()` modifier and the mergeable
29
+ * capability (so both {@link MergeProp} and {@link DeferProp} can be merged on the client).
30
+ *
31
+ * @category Props
32
+ */
33
+ export abstract class InertiaProp {
34
+ protected _once = false;
35
+ protected _onceExpiresAt: number | null = null;
36
+
37
+ protected _merge = false;
38
+ protected _deep = false;
39
+ protected _appendPaths: string[] = [];
40
+ protected _prependPaths: string[] = [];
41
+ protected _matchOn: string[] = [];
42
+ protected _prependRoot = false;
43
+
44
+ /** When true, the prop is skipped on full/initial visits and only resolved on partial reloads. */
45
+ readonly ignoreFirstLoad: boolean = false;
46
+
47
+ /** Produce the prop's value (may be async). */
48
+ abstract resolve(): unknown | Promise<unknown>;
49
+
50
+ // ── once() ──────────────────────────────────────────────────────────────────
51
+
52
+ /** Resolve only once; the client remembers the value across subsequent navigations. */
53
+ once(expiresAt: number | null = null): this {
54
+ this._once = true;
55
+ this._onceExpiresAt = expiresAt;
56
+ return this;
57
+ }
58
+ get isOnce(): boolean {
59
+ return this._once;
60
+ }
61
+ get onceExpiresAt(): number | null {
62
+ return this._onceExpiresAt;
63
+ }
64
+
65
+ // ── mergeable ────────────────────────────────────────────────────────────────
66
+
67
+ /** Append new items to the existing array at the root level on partial reloads. */
68
+ merge(): this {
69
+ this._merge = true;
70
+ return this;
71
+ }
72
+ /** Deep-merge the whole structure into the existing prop value on partial reloads. */
73
+ deepMerge(): this {
74
+ this._merge = true;
75
+ this._deep = true;
76
+ return this;
77
+ }
78
+ /** Append to specific nested path(s) of the prop, replacing the rest. */
79
+ append(paths?: string | string[]): this {
80
+ this._merge = true;
81
+ this._push(this._appendPaths, paths);
82
+ return this;
83
+ }
84
+ /** Prepend at the root (no args) or to specific nested path(s). */
85
+ prepend(paths?: string | string[]): this {
86
+ this._merge = true;
87
+ if (paths === undefined) this._prependRoot = true;
88
+ else this._push(this._prependPaths, paths);
89
+ return this;
90
+ }
91
+ /** Match existing items by a field (path's last segment) instead of appending duplicates. */
92
+ matchOn(paths: string | string[]): this {
93
+ this._push(this._matchOn, paths);
94
+ return this;
95
+ }
96
+
97
+ get shouldMerge(): boolean {
98
+ return this._merge;
99
+ }
100
+ mergeConfig(): MergeConfig {
101
+ return {
102
+ deep: this._deep,
103
+ appendPaths: this._appendPaths,
104
+ prependPaths: this._prependPaths,
105
+ matchOn: this._matchOn,
106
+ prependRoot: this._prependRoot,
107
+ };
108
+ }
109
+
110
+ private _push(target: string[], paths?: string | string[]): void {
111
+ if (paths === undefined) return;
112
+ if (Array.isArray(paths)) target.push(...paths);
113
+ else target.push(paths);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * `optional(fn)` — never included unless explicitly requested via a partial reload's `only`.
119
+ * Also the underlying type returned by `lazy()`.
120
+ *
121
+ * @category Props
122
+ */
123
+ export class OptionalProp extends InertiaProp {
124
+ override readonly ignoreFirstLoad = true;
125
+ constructor(private readonly callback: PropFactory) {
126
+ super();
127
+ }
128
+ resolve(): unknown | Promise<unknown> {
129
+ return this.callback();
130
+ }
131
+ }
132
+
133
+ /**
134
+ * `always(value)` — always included, even when a partial reload's `only`/`except` would exclude it.
135
+ * Used internally to share `errors`.
136
+ *
137
+ * @category Props
138
+ */
139
+ export class AlwaysProp extends InertiaProp {
140
+ constructor(private readonly value: unknown | PropFactory) {
141
+ super();
142
+ }
143
+ resolve(): unknown | Promise<unknown> {
144
+ return typeof this.value === "function" ? (this.value as PropFactory)() : this.value;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * `defer(fn, group?, { rescue })` — excluded from the initial render and listed under
150
+ * `deferredProps[group]`; the client fetches it in a follow-up partial reload. With `rescue: true`,
151
+ * a thrown error is swallowed and the key is reported in `rescuedProps`.
152
+ *
153
+ * @category Props
154
+ */
155
+ export class DeferProp extends InertiaProp {
156
+ override readonly ignoreFirstLoad = true;
157
+ constructor(
158
+ private readonly callback: PropFactory,
159
+ readonly group: string = "default",
160
+ readonly rescue: boolean = false,
161
+ ) {
162
+ super();
163
+ }
164
+ resolve(): unknown | Promise<unknown> {
165
+ return this.callback();
166
+ }
167
+ }
168
+
169
+ /**
170
+ * `merge(value)` / `deepMerge(value)` — the client merges (rather than replaces) the prop on
171
+ * partial reloads. Chain `.append()`, `.prepend()`, `.matchOn()`, `.deepMerge()`.
172
+ *
173
+ * @category Props
174
+ */
175
+ export class MergeProp extends InertiaProp {
176
+ constructor(private readonly value: unknown | PropFactory) {
177
+ super();
178
+ this._merge = true;
179
+ }
180
+ resolve(): unknown | Promise<unknown> {
181
+ return typeof this.value === "function" ? (this.value as PropFactory)() : this.value;
182
+ }
183
+ }
184
+
185
+ /** Minimal paginator shape `scroll()` reads to compute scroll metadata. */
186
+ export interface PaginatorLike {
187
+ data: unknown[];
188
+ /** Current page — read from `currentPage` or `page`. */
189
+ currentPage?: number;
190
+ page?: number;
191
+ /** Last page number — read from `lastPage` (or derived from total/perPage). */
192
+ lastPage?: number;
193
+ perPage?: number;
194
+ total?: number;
195
+ }
196
+
197
+ /** The `scrollProps` config emitted per infinite-scroll prop. */
198
+ export interface ScrollConfig {
199
+ pageName: string;
200
+ previousPage: number | null;
201
+ nextPage: number | null;
202
+ currentPage: number | null;
203
+ }
204
+
205
+ /**
206
+ * `scroll(paginator)` — an infinite-scroll prop. The page's paginated data is merged (the client
207
+ * appends/prepends new pages) and the page object carries a `scrollProps` entry describing the
208
+ * current/next/previous page so the `<InfiniteScroll>` component knows when to load more.
209
+ *
210
+ * @category Props
211
+ */
212
+ export class InfiniteScrollProp extends InertiaProp {
213
+ constructor(
214
+ private readonly value: PaginatorLike | PropFactory,
215
+ readonly pageName: string = "page",
216
+ readonly dataPath: string = "data",
217
+ ) {
218
+ super();
219
+ this._merge = true;
220
+ this._appendPaths = [dataPath];
221
+ }
222
+
223
+ resolve(): unknown | Promise<unknown> {
224
+ return typeof this.value === "function" ? (this.value as PropFactory)() : this.value;
225
+ }
226
+
227
+ /** Compute the `scrollProps` entry from the resolved paginator value. */
228
+ scrollConfig(resolved: unknown): ScrollConfig {
229
+ const p = (resolved ?? {}) as PaginatorLike;
230
+ const current = p.currentPage ?? p.page ?? null;
231
+ const last =
232
+ p.lastPage ??
233
+ (p.total != null && p.perPage ? Math.max(1, Math.ceil(p.total / p.perPage)) : null);
234
+ const nextPage = current != null && last != null && current < last ? current + 1 : null;
235
+ const previousPage = current != null && current > 1 ? current - 1 : null;
236
+ return { pageName: this.pageName, previousPage, nextPage, currentPage: current };
237
+ }
238
+ }
239
+
240
+ // ── factory helpers ────────────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * Never include this prop on a full visit; the callback runs (and the value is sent)
244
+ * only when a partial reload names the key in its `only` set. Use for expensive data
245
+ * a page fetches on demand.
246
+ *
247
+ * @param callback - Factory producing the prop value (may be async); evaluated only when included.
248
+ * @returns An {@link OptionalProp} wrapper for the resolver.
249
+ * @category Props
250
+ * @example
251
+ * ```ts
252
+ * return inertia('Users/Index', {
253
+ * users,
254
+ * // Only fetched when the client does `router.reload({ only: ['stats'] })`.
255
+ * stats: optional(() => computeExpensiveStats()),
256
+ * });
257
+ * ```
258
+ */
259
+ export function optional(callback: PropFactory): OptionalProp {
260
+ return new OptionalProp(callback);
261
+ }
262
+
263
+ /**
264
+ * Alias of {@link optional}, matching Inertia's historical `lazy()` name.
265
+ *
266
+ * @param callback - Factory producing the prop value (may be async); evaluated only when included.
267
+ * @returns An {@link OptionalProp} wrapper.
268
+ * @category Props
269
+ */
270
+ export function lazy(callback: PropFactory): OptionalProp {
271
+ return new OptionalProp(callback);
272
+ }
273
+
274
+ /**
275
+ * Always include this prop, even when a partial reload's `only`/`except` would
276
+ * otherwise exclude it. Used internally to keep the `errors` bag present on every visit.
277
+ *
278
+ * @param value - The value, or a factory producing it (evaluated when included).
279
+ * @returns An {@link AlwaysProp} wrapper.
280
+ * @category Props
281
+ */
282
+ export function always(value: unknown | PropFactory): AlwaysProp {
283
+ return new AlwaysProp(value);
284
+ }
285
+
286
+ /**
287
+ * Exclude this prop from the initial render and load it in an automatic follow-up
288
+ * request, so the page paints immediately and heavier data streams in after. Props
289
+ * sharing a `group` are fetched together in one request.
290
+ *
291
+ * @param callback - Factory producing the prop value (may be async); runs on the deferred request.
292
+ * @param group - Request group name; props with the same group load in one follow-up request. Default `"default"`.
293
+ * @param options - `rescue: true` swallows a thrown error and reports the key in `rescuedProps` instead of failing the request.
294
+ * @returns A {@link DeferProp} wrapper.
295
+ * @category Props
296
+ * @example
297
+ * ```ts
298
+ * return inertia('Dashboard', {
299
+ * user,
300
+ * stats: defer(() => computeStats()), // group "default"
301
+ * activity: defer(() => recentActivity(), 'secondary'), // separate request
302
+ * });
303
+ * ```
304
+ */
305
+ export function defer(
306
+ callback: PropFactory,
307
+ group = "default",
308
+ options: { rescue?: boolean } = {},
309
+ ): DeferProp {
310
+ return new DeferProp(callback, group, options.rescue ?? false);
311
+ }
312
+
313
+ /**
314
+ * Mark this prop so the client appends (merges) it into the existing value on partial
315
+ * reloads instead of replacing it — the basis for "load more" lists. Chain
316
+ * `.append()` / `.prepend()` / `.matchOn()` on the returned wrapper for finer control.
317
+ *
318
+ * @param value - The value, or a factory producing it.
319
+ * @returns A {@link MergeProp} wrapper.
320
+ * @category Props
321
+ * @example
322
+ * ```ts
323
+ * return inertia('Feed', { posts: merge(() => Post.paginate(15, page)) });
324
+ * ```
325
+ */
326
+ export function merge(value: unknown | PropFactory): MergeProp {
327
+ return new MergeProp(value);
328
+ }
329
+
330
+ /**
331
+ * Like {@link merge}, but the client deep-merges the structure into the existing prop
332
+ * value on partial reloads rather than appending at the root.
333
+ *
334
+ * @param value - The value, or a factory producing it.
335
+ * @returns A {@link MergeProp} wrapper configured for deep merging.
336
+ * @category Props
337
+ */
338
+ export function deepMerge(value: unknown | PropFactory): MergeProp {
339
+ return new MergeProp(value).deepMerge();
340
+ }
341
+
342
+ /**
343
+ * Infinite-scroll prop: merges paginated data and emits `scrollProps` so the client
344
+ * `<InfiniteScroll>` component knows when to load the next/previous page.
345
+ *
346
+ * @param value - A paginator (or factory producing one) with `data` plus page metadata.
347
+ * @param options - `pageName` (query param driving pagination, default `"page"`) and `dataPath` (path to the array to merge, default `"data"`).
348
+ * @returns An {@link InfiniteScrollProp} wrapper.
349
+ * @category Props
350
+ * @example
351
+ * ```ts
352
+ * return inertia('Posts/Index', { posts: scroll(() => Post.paginate(15, page)) });
353
+ * // custom page-query name:
354
+ * return inertia('Items', { items: scroll(() => Item.paginate(15, page), { pageName: 'p' }) });
355
+ * ```
356
+ */
357
+ export function scroll(
358
+ value: PaginatorLike | PropFactory,
359
+ options: { pageName?: string; dataPath?: string } = {},
360
+ ): InfiniteScrollProp {
361
+ return new InfiniteScrollProp(value, options.pageName ?? "page", options.dataPath ?? "data");
362
+ }
@@ -0,0 +1,189 @@
1
+ import {
2
+ InertiaProp,
3
+ AlwaysProp,
4
+ DeferProp,
5
+ InfiniteScrollProp,
6
+ type PropFactory,
7
+ type ScrollConfig,
8
+ } from "./PropTypes.ts";
9
+
10
+ /** The resolved props plus the page-object metadata the client needs to merge/defer correctly. */
11
+ export interface ResolvedPage {
12
+ props: Record<string, unknown>;
13
+ deferredProps?: Record<string, string[]>;
14
+ mergeProps?: string[];
15
+ prependProps?: string[];
16
+ deepMergeProps?: string[];
17
+ matchPropsOn?: string[];
18
+ scrollProps?: Record<string, ScrollConfig>;
19
+ onceProps?: Record<string, { prop: string; expiresAt: number | null }>;
20
+ rescuedProps?: string[];
21
+ }
22
+
23
+ function parseList(value: string | null): string[] {
24
+ if (!value) return [];
25
+ return value
26
+ .split(",")
27
+ .map((s) => s.trim())
28
+ .filter(Boolean);
29
+ }
30
+
31
+ async function evaluate(value: unknown): Promise<unknown> {
32
+ if (value instanceof InertiaProp) return await value.resolve();
33
+ if (typeof value === "function") return await (value as PropFactory)();
34
+ return value;
35
+ }
36
+
37
+ /**
38
+ * Resolve a raw prop map against the current request into the props payload plus page-object
39
+ * metadata, implementing the Inertia v3 prop protocol:
40
+ *
41
+ * - **Partial reloads**: when `X-Inertia-Partial-Component` matches `component`, only the props in
42
+ * `X-Inertia-Partial-Data` (`only`) are returned, or everything except `X-Inertia-Partial-Except`
43
+ * (`except`, which takes precedence).
44
+ * - **Lazy evaluation**: function and wrapper props are only evaluated when actually included.
45
+ * - **optional/defer** (`ignoreFirstLoad`): omitted from full visits; included only when named in a
46
+ * partial reload's `only`. Deferred props are advertised in `deferredProps[group]` on first load.
47
+ * - **always**: always included, regardless of only/except.
48
+ * - **merge/deepMerge**: included normally, but advertised in `mergeProps`/`deepMergeProps`/
49
+ * `prependProps`/`matchPropsOn` so the client merges instead of replacing. Suppressed for keys in
50
+ * `X-Inertia-Reset`.
51
+ * - **once**: advertised in `onceProps`; skipped (not re-resolved) when already loaded on the client
52
+ * per `X-Inertia-Except-Once-Props`, unless explicitly requested via `only`.
53
+ * - **rescue**: a deferred prop with `rescue: true` that throws is omitted and reported in
54
+ * `rescuedProps`.
55
+ *
56
+ * @param raw - The merged raw prop map (shared props + controller props), values possibly wrapped.
57
+ * @param headers - The incoming request headers, read for the `X-Inertia-Partial-*` / reset / once / scroll-intent directives.
58
+ * @param component - The page component name, compared against `X-Inertia-Partial-Component` to detect a partial reload.
59
+ * @returns The resolved props plus the merge/defer/scroll/once metadata for the page object.
60
+ * @internal Prop-protocol engine behind {@link buildPageObject}; not part of the app-facing API.
61
+ */
62
+ export async function resolveProps(
63
+ raw: Record<string, unknown>,
64
+ headers: Headers,
65
+ component: string,
66
+ ): Promise<ResolvedPage> {
67
+ const isPartial = headers.get("X-Inertia-Partial-Component") === component;
68
+ const only = parseList(headers.get("X-Inertia-Partial-Data"));
69
+ const except = parseList(headers.get("X-Inertia-Partial-Except"));
70
+ const reset = new Set(parseList(headers.get("X-Inertia-Reset")));
71
+ const onceLoaded = new Set(parseList(headers.get("X-Inertia-Except-Once-Props")));
72
+ const scrollIntent = headers.get("X-Inertia-Infinite-Scroll-Merge-Intent"); // "prepend" | "append"
73
+
74
+ const keys = Object.keys(raw);
75
+ const propOf = (key: string): InertiaProp | undefined => {
76
+ const v = raw[key];
77
+ return v instanceof InertiaProp ? v : undefined;
78
+ };
79
+
80
+ // ── 1. Decide which keys are included ────────────────────────────────────────
81
+ const included: string[] = [];
82
+ for (const key of keys) {
83
+ const prop = propOf(key);
84
+
85
+ // always() props bypass all filtering.
86
+ if (prop instanceof AlwaysProp) {
87
+ included.push(key);
88
+ continue;
89
+ }
90
+
91
+ if (isPartial && (only.length || except.length)) {
92
+ if (except.length) {
93
+ // except takes precedence; exclude listed keys and never auto-include ignoreFirstLoad props.
94
+ if (!except.includes(key) && !prop?.ignoreFirstLoad) included.push(key);
95
+ } else if (only.includes(key)) {
96
+ // only: include exactly these (optional/deferred props become available here).
97
+ included.push(key);
98
+ }
99
+ continue;
100
+ }
101
+
102
+ // Full/initial visit (or partial reload with no only/except): everything except
103
+ // ignoreFirstLoad props (optional/deferred), which load on a later request.
104
+ if (!prop?.ignoreFirstLoad) included.push(key);
105
+ }
106
+
107
+ // ── 2. Resolve included values + collect once metadata ───────────────────────
108
+ const props: Record<string, unknown> = {};
109
+ const rescuedProps: string[] = [];
110
+ const onceProps: Record<string, { prop: string; expiresAt: number | null }> = {};
111
+
112
+ for (const key of included) {
113
+ const prop = propOf(key);
114
+
115
+ if (prop?.isOnce) {
116
+ onceProps[key] = { prop: key, expiresAt: prop.onceExpiresAt };
117
+ // Already on the client and not explicitly re-requested → skip resolving, reuse client value.
118
+ if (onceLoaded.has(key) && !only.includes(key)) continue;
119
+ }
120
+
121
+ try {
122
+ props[key] = await evaluate(raw[key]);
123
+ } catch (err) {
124
+ if (prop instanceof DeferProp && prop.rescue) {
125
+ rescuedProps.push(key);
126
+ continue;
127
+ }
128
+ throw err;
129
+ }
130
+ }
131
+
132
+ // ── 3. Deferred-prop advertisement (initial visit only) ──────────────────────
133
+ const deferredProps: Record<string, string[]> = {};
134
+ if (!isPartial) {
135
+ for (const key of keys) {
136
+ const v = raw[key];
137
+ if (v instanceof DeferProp) (deferredProps[v.group] ??= []).push(key);
138
+ }
139
+ }
140
+
141
+ // ── 4. Merge advertisement (for included, non-reset mergeable props) ─────────
142
+ const mergeProps: string[] = [];
143
+ const prependProps: string[] = [];
144
+ const deepMergeProps: string[] = [];
145
+ const matchPropsOn: string[] = [];
146
+
147
+ for (const key of included) {
148
+ const prop = propOf(key);
149
+ if (!prop?.shouldMerge || reset.has(key)) continue;
150
+ // Skip merge advertisement for once props that were reused (not in props).
151
+ if (prop.isOnce && !(key in props)) continue;
152
+
153
+ const cfg = prop.mergeConfig();
154
+ const target = cfg.deep ? deepMergeProps : mergeProps;
155
+ const hasNested = cfg.appendPaths.length > 0 || cfg.prependPaths.length > 0 || cfg.prependRoot;
156
+
157
+ // Infinite-scroll props follow the client's merge intent: append by default, prepend when the
158
+ // user scrolled up (X-Inertia-Infinite-Scroll-Merge-Intent: prepend).
159
+ const appendTarget =
160
+ prop instanceof InfiniteScrollProp && scrollIntent === "prepend" ? prependProps : target;
161
+
162
+ if (!hasNested) target.push(key);
163
+ for (const p of cfg.appendPaths) appendTarget.push(`${key}.${p}`);
164
+ for (const p of cfg.prependPaths) prependProps.push(`${key}.${p}`);
165
+ if (cfg.prependRoot) prependProps.push(key);
166
+ for (const p of cfg.matchOn) matchPropsOn.push(`${key}.${p}`);
167
+ }
168
+
169
+ // ── 4b. Infinite-scroll metadata ─────────────────────────────────────────────
170
+ const scrollProps: Record<string, ScrollConfig> = {};
171
+ for (const key of included) {
172
+ const prop = propOf(key);
173
+ if (prop instanceof InfiniteScrollProp && key in props) {
174
+ scrollProps[key] = prop.scrollConfig(props[key]);
175
+ }
176
+ }
177
+
178
+ // ── 5. Assemble, omitting empty metadata ─────────────────────────────────────
179
+ const out: ResolvedPage = { props };
180
+ if (Object.keys(deferredProps).length) out.deferredProps = deferredProps;
181
+ if (mergeProps.length) out.mergeProps = mergeProps;
182
+ if (prependProps.length) out.prependProps = prependProps;
183
+ if (deepMergeProps.length) out.deepMergeProps = deepMergeProps;
184
+ if (matchPropsOn.length) out.matchPropsOn = matchPropsOn;
185
+ if (Object.keys(scrollProps).length) out.scrollProps = scrollProps;
186
+ if (Object.keys(onceProps).length) out.onceProps = onceProps;
187
+ if (rescuedProps.length) out.rescuedProps = rescuedProps;
188
+ return out;
189
+ }