bitboss-ui 3.0.0-beta.64 → 3.0.0-beta.65

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 (36) hide show
  1. package/dist/ai/changelog.json +82 -4
  2. package/dist/ai/components.json +2 -2
  3. package/dist/ai/composables/useBbConfig.md +8 -1
  4. package/dist/ai/guides/inertia-helpers.md +1127 -275
  5. package/dist/ai/guides/migration/v2-to-v3.md +48 -0
  6. package/dist/ai/guides/passthrough.md +4 -2
  7. package/dist/ai/helpers/inertia/manifest.json +145 -62
  8. package/dist/ai/helpers/inertia/messages.ts +33 -0
  9. package/dist/ai/helpers/inertia/useConfirmOnRouteLeave.ts +116 -16
  10. package/dist/ai/helpers/inertia/useHasChanged.ts +34 -16
  11. package/dist/ai/helpers/inertia/useLazyProp.ts +102 -36
  12. package/dist/ai/helpers/inertia/useLazyPropGetter.ts +3 -1
  13. package/dist/ai/helpers/inertia/usePaginatedResponseContext.ts +129 -24
  14. package/dist/ai/helpers/inertia/usePromiseForm.ts +121 -14
  15. package/dist/ai/helpers/inertia/useQuery.ts +133 -11
  16. package/dist/ai/helpers/inertia/useQueryObject.ts +74 -67
  17. package/dist/ai/helpers/inertia/useResourceContext.ts +60 -14
  18. package/dist/ai/helpers/inertia/useRouteQuery.ts +29 -24
  19. package/dist/ai/helpers/inertia/useScreenSizes.ts +9 -7
  20. package/dist/ai/helpers/inertia/useTableContext.ts +5 -2
  21. package/dist/ai/helpers/inertia/utils.ts +14 -0
  22. package/dist/ai/manifest/index.json +2 -2
  23. package/dist/ai/recipes/inertia/approvals-inbox.md +1 -1
  24. package/dist/ai/recipes/inertia/inline-edit-workspace.md +1 -1
  25. package/dist/ai/recipes/inertia/ownership-atlas.md +1 -1
  26. package/dist/ai/recipes/inertia/records-workspace.md +1 -1
  27. package/dist/ai/recipes/inertia/upload-center.md +1 -1
  28. package/dist/ai/source/BbSelectPopover.md +25 -1
  29. package/dist/components/BbSelectPopover/BbSelectPopover.vue_vue_type_script_setup_true_lang.js +306 -294
  30. package/dist/composables/useBbConfig.d.ts +1 -0
  31. package/dist/composables/useBbConfig.js +11 -7
  32. package/dist/llms-full.txt +1192 -283
  33. package/dist/styles.css +1 -1
  34. package/dist/utils/versionCheck.js +1 -1
  35. package/package.json +3 -2
  36. package/scripts/lib/helpers-add.mjs +26 -2
@@ -76,6 +76,12 @@ nothing imports them. Your own `@/utils` and `@/types` stay yours. If the app
76
76
  auto-imports `composables/**`, exclude one of the two trees until the old
77
77
  copies are gone, or both export `useQuery`.
78
78
 
79
+ The helpers run and type-check on Inertia 2 and 3 (`@inertiajs/vue3` 2.x or
80
+ 3.x). On Inertia 2, `useLazyProp`'s reload cannot keep the page's validation
81
+ errors (Inertia 2 has no `preserveErrors`), and `usePromiseForm` hears a failed
82
+ save through Inertia's global `invalid` / `exception` events, so an async reload
83
+ that fails while a save is out counts as that save failing.
84
+
79
85
  The helpers need these npm packages beyond the app's own
80
86
  `@inertiajs/vue3`/`vue` (`add` prints the ones a helper needs). Install them
81
87
  explicitly rather than relying on them being hoisted through `bitboss-ui`'s own
@@ -91,10 +97,12 @@ npm i -D @inertiajs/core
91
97
  `usePageShellContext`) and `useDeleteCmd`/`useScreenSizes`; `object-code`
92
98
  powers the `isEqual` hash-comparison utility below; `query-string` backs
93
99
  `useQuery`/`useRouteQuery`/`useQueryObject`; `@inertiajs/core` is a type-only
94
- import (`PageProps` in `usePaginatedResponseContext`) that `@inertiajs/vue3`
95
- does not re-export, so a strict pnpm app fails to type-check without it. The
96
- helpers' shared types and tiny utilities are two files of the folder,
97
- `types.ts` and `utils.ts`, below.
100
+ import (`PageProps`, `VisitOptions`, in the URL and context helpers) that `@inertiajs/vue3`
101
+ does not re-export, so a strict pnpm app fails to type-check without it. Install
102
+ the version your `@inertiajs/vue3` runs on (`add` prints it pinned, e.g.
103
+ `@inertiajs/core@3.1.1`): a newer core's visit types do not match. The
104
+ helpers' shared types, tiny utilities and words are three files of the
105
+ folder, `types.ts`, `utils.ts` and `messages.ts`, below.
98
106
 
99
107
  ## Shared types and utilities
100
108
 
@@ -158,10 +166,76 @@ export const isPlainObject = (
158
166
  typeof value === 'object' &&
159
167
  value !== null &&
160
168
  Object.getPrototypeOf(value) === Object.prototype;
169
+
170
+ /**
171
+ * Deep copy of server data (arrays and plain objects; anything else, e.g. a
172
+ * Date an app put there, is shared). Reads through Vue proxies, which
173
+ * `structuredClone` refuses.
174
+ */
175
+ export const cloneDeep = <T>(value: T): T =>
176
+ (Array.isArray(value)
177
+ ? value.map(cloneDeep)
178
+ : isPlainObject(value)
179
+ ? Object.fromEntries(
180
+ Object.entries(value).map(([key, item]) => [key, cloneDeep(item)])
181
+ )
182
+ : value) as T;
183
+ ```
184
+
185
+ `messages.ts` holds the words a helper shows (today the leave guard's dialog),
186
+ by the library's `locale` (`useBbConfig().locale`): `it-IT`, else `it`, else
187
+ `en`. It is the one helper file meant to be edited: add your locales next to
188
+ `en`. `helpers update` keeps your copy; when a release adds words here, it
189
+ holds back this file and the helpers that read it until you merge them
190
+ (`npx bitboss-ui helpers diff messages`).
191
+
192
+ ```ts
193
+ // inertia-helpers/messages.ts
194
+ // The words the helpers show, by the library's `locale`. Yours to edit: add
195
+ // your app's locales next to `en` (keyed as you set `locale`: `it`, or `it-IT`
196
+ // to tell regions apart). `helpers update` keeps your copy; if a release
197
+ // adds words here, `npx bitboss-ui helpers diff messages` shows what to merge.
198
+ import { useBbConfig } from 'bitboss-ui';
199
+ import { computed } from 'vue';
200
+
201
+ export type HelperMessages = {
202
+ leave: { title: string; text: string; yes: string; no: string };
203
+ };
204
+
205
+ export const messages: Record<string, HelperMessages> = {
206
+ en: {
207
+ leave: {
208
+ title: 'Unsaved changes',
209
+ text: 'You have unsaved changes. Leave without saving?',
210
+ yes: 'Discard changes',
211
+ no: 'Keep editing',
212
+ },
213
+ },
214
+ };
215
+
216
+ /** The messages for the current locale: `it-IT`, else `it`, else `en`. */
217
+ export const useHelperMessages = () => {
218
+ const { locale } = useBbConfig();
219
+ return computed(
220
+ () =>
221
+ messages[locale.value] ??
222
+ messages[locale.value.split('-')[0]!.toLowerCase()] ??
223
+ messages.en!
224
+ );
225
+ };
161
226
  ```
162
227
 
163
228
  ---
164
229
 
230
+ **Unit-testing code that uses the helpers.** A test that mocks
231
+ `@inertiajs/vue3` has to follow Inertia's own contract, or the helpers read the
232
+ mock as a refused or broken visit: `router.visit` / `reload` call `onStart`
233
+ synchronously (a visit with no `onStart` counts as refused before it started);
234
+ `onFinish` receives the visit with `cancelled` / `interrupted` / `completed`;
235
+ `router.on` returns its unbind function. `usePaginatedResponseContext` clamps a
236
+ page past the end in `onMounted`, so call it from a mounted component, not a
237
+ bare `effectScope`.
238
+
165
239
  ## Page props
166
240
 
167
241
  ### useProp / useOptionalProp
@@ -254,9 +328,17 @@ when the data lands. `loading`/`loaded`/`error` expose the fetch state;
254
328
  ```ts
255
329
  // inertia-helpers/useLazyProp.ts
256
330
  import { isEqual, isNil, isNotNil } from './utils';
331
+ import type { PageProps } from '@inertiajs/core';
257
332
  import { router, usePage } from '@inertiajs/vue3';
258
333
  import type { Ref } from 'vue';
259
- import { customRef, onBeforeUnmount, readonly, ref, watch } from 'vue';
334
+ import {
335
+ customRef,
336
+ nextTick,
337
+ onBeforeUnmount,
338
+ readonly,
339
+ ref,
340
+ watch,
341
+ } from 'vue';
260
342
 
261
343
  export type UseLazyPropOptions = {
262
344
  /** Keep the last non-nil value across visits that reset the prop. Default true. */
@@ -288,7 +370,9 @@ export function useLazyProp<T>(
288
370
  initialValue?: T,
289
371
  options: UseLazyPropOptions = {}
290
372
  ): UseLazyProp<T | undefined> {
291
- const page = usePage<{ [key: string]: T | undefined }>();
373
+ // `PageProps &` keeps usePage's constraint satisfied in an app that
374
+ // augments Inertia's `PageProps` (TS2344 otherwise).
375
+ const page = usePage<PageProps & { [key: string]: T | undefined }>();
292
376
  const { cache = true } = options;
293
377
  const loading = ref(false);
294
378
  const loaded = ref(page.props[propName] !== undefined);
@@ -296,50 +380,107 @@ export function useLazyProp<T>(
296
380
  let inFlightPromise: Promise<void> | null = null;
297
381
  let isUnmounted = false;
298
382
  let cached = page.props[propName];
383
+ // Settles a reload a navigation cancelled, once that navigation is over.
384
+ let resumeCancelled: (() => void) | null = null;
299
385
 
300
386
  onBeforeUnmount(() => {
301
387
  isUnmounted = true;
388
+ resumeCancelled?.();
302
389
  });
303
390
 
304
391
  const loadProp = (onSuccess: () => void): Promise<void> => {
305
392
  if (inFlightPromise) return inFlightPromise;
306
393
 
307
394
  error.value = null;
308
-
309
- inFlightPromise = new Promise<void>((resolve) => {
310
- router.reload({
311
- only: [propName],
312
- replace: true,
313
- ...(options.headers ? { headers: options.headers } : {}),
314
- onStart: () => {
315
- loading.value = true;
316
- },
317
- onSuccess: () => {
318
- if (!isUnmounted) {
319
- loaded.value = true;
320
- cached = page.props[propName];
321
- onSuccess();
322
- }
323
- },
324
- onError: (errors) => {
325
- if (!isUnmounted) {
326
- error.value =
327
- errors instanceof Error
328
- ? errors
329
- : new Error(`Failed to load lazy prop "${propName}"`);
330
- }
331
- },
332
- onFinish: () => {
333
- if (!isUnmounted) {
334
- loading.value = false;
335
- }
336
- inFlightPromise = null;
337
- resolve();
338
- },
339
- });
395
+ let started = false;
396
+ let succeeded = false;
397
+ let settle!: () => void;
398
+ const promise = new Promise<void>((resolve) => {
399
+ settle = () => {
400
+ if (!isUnmounted) loading.value = false;
401
+ if (inFlightPromise === promise) inFlightPromise = null;
402
+ resolve();
403
+ };
404
+ });
405
+ inFlightPromise = promise;
406
+
407
+ router.reload({
408
+ only: [propName],
409
+ replace: true,
410
+ // Like Inertia's own deferred-props loader: keep the page's
411
+ // validation errors (Inertia 3; Inertia 2 has no such option).
412
+ ...({ preserveErrors: true } as object),
413
+ ...(options.headers ? { headers: options.headers } : {}),
414
+ onStart: () => {
415
+ started = true;
416
+ loading.value = true;
417
+ },
418
+ onSuccess: () => {
419
+ succeeded = true;
420
+ if (!isUnmounted) {
421
+ loaded.value = true;
422
+ cached = page.props[propName];
423
+ onSuccess();
424
+ }
425
+ },
426
+ onError: (errors) => {
427
+ if (!isUnmounted) {
428
+ error.value =
429
+ errors instanceof Error
430
+ ? errors
431
+ : new Error(`Failed to load lazy prop "${propName}"`);
432
+ }
433
+ },
434
+ onFinish: (visit) => {
435
+ // A visit to another page cancels this reload: no failure. Stay
436
+ // loading (a reader must not re-fire it at the outgoing URL) until
437
+ // that navigation is over, then retry if this page is still
438
+ // mounted (a same-component visit) or settle if it unmounted.
439
+ if (visit.cancelled) {
440
+ const cancelledOn = page.url;
441
+ const unbindFinish = router.on('finish', (event) => {
442
+ const next = event.detail.visit;
443
+ if (next.cancelled || next.interrupted) return;
444
+ // An async visit is over too (a mutation answered on another
445
+ // URL redirects back with no other visit), unless it is one
446
+ // of this page's own reloads (a poll, another lazy prop).
447
+ if (
448
+ next.async &&
449
+ next.url.href === new URL(cancelledOn, next.url).href
450
+ )
451
+ return;
452
+ resumeCancelled?.();
453
+ });
454
+ // Back / Forward swaps the page with no finish event.
455
+ const unbindNavigate = router.on('navigate', () =>
456
+ resumeCancelled?.()
457
+ );
458
+ resumeCancelled = () => {
459
+ resumeCancelled = null;
460
+ unbindFinish();
461
+ unbindNavigate();
462
+ void nextTick(() => {
463
+ inFlightPromise = null;
464
+ if (isUnmounted || loaded.value) settle();
465
+ else void loadProp(onSuccess).then(settle);
466
+ });
467
+ };
468
+ return;
469
+ }
470
+ // A network error or a non-Inertia response (Inertia's error
471
+ // dialog) reaches only onFinish: report it. The self-loading read
472
+ // does not retry a failed load; `execute()` does.
473
+ if (!succeeded && !error.value && !isUnmounted) {
474
+ error.value = new Error(`Failed to load lazy prop "${propName}"`);
475
+ }
476
+ settle();
477
+ },
340
478
  });
479
+ // Refused before it started (a `before` listener cancelled it, or no
480
+ // `window` during SSR): no callback will ever come.
481
+ if (!started) settle();
341
482
 
342
- return inFlightPromise;
483
+ return promise;
343
484
  };
344
485
 
345
486
  /** Self-loading ref: reading it while the prop is absent starts the fetch. */
@@ -348,8 +489,7 @@ export function useLazyProp<T>(
348
489
  track();
349
490
  const isMissing = page.props[propName] === undefined;
350
491
 
351
- if (isMissing && !loaded.value && !loading.value) {
352
- loading.value = true;
492
+ if (isMissing && !loaded.value && !loading.value && !error.value) {
353
493
  void loadProp(trigger);
354
494
  }
355
495
 
@@ -408,6 +548,10 @@ Gotchas:
408
548
  load `stats.revenue`, only `stats`.
409
549
  - Filter navigations reset lazy props server-side; with `cache: true`
410
550
  (default) the last value keeps rendering while the fresh one loads.
551
+ - A failed load (offline, a non-Inertia 500) sets `error` and is **not**
552
+ retried by the next read — that would be a request storm. `execute()`
553
+ retries. A load a navigation cancels is no failure: it stays `loading`
554
+ until that navigation is over, then retries if the page is still mounted.
411
555
 
412
556
  ### useLazyPropGetter
413
557
 
@@ -458,7 +602,9 @@ export function useLazyPropGetter<T, U = T>(
458
602
  if (!loaded.value) {
459
603
  await execute();
460
604
  }
461
- if (isNil(value.value)) {
605
+ // `loaded` first: reading `value` while unloaded would start a
606
+ // second, background reload.
607
+ if (!loaded.value || isNil(value.value)) {
462
608
  throw new Error(`Could not load lazy prop "${propName}"`);
463
609
  }
464
610
  if (isNil(formatter)) return value.value as unknown as U;
@@ -496,29 +642,163 @@ navigation.
496
642
 
497
643
  ### useQuery
498
644
 
499
- Parses the current page URL's query string into an object. Snapshot, not
500
- reactive — used by the writers below.
645
+ Parses the current page URL's query string into an object, the way the
646
+ server reads it: decoded once, comma arrays split (an encoded `%2C` too, as
647
+ PHP decodes it before Spatie splits — so with the comma format a comma inside
648
+ a free-text value reads back as a separator, exactly as the server sees it).
649
+ Snapshot, not reactive — used by the writers below.
650
+
651
+ The file is also the one URL writer every helper shares (`withQuery`,
652
+ `visitUrl`): while a write's visit is in flight, every reader sees the URL it
653
+ is heading to and every writer builds on it. So a sort and a direction set in
654
+ one click both land, a filter change followed by `page = 1` keeps the filter,
655
+ undoing a write before the server answers is not swallowed, and a ref reads
656
+ back what was just written. Any visit that lands supersedes it. A write
657
+ changes only its own keys: the other parameters keep their order and
658
+ encoding, and a `#fragment` stays a fragment.
501
659
 
502
660
  ```ts
503
661
  // inertia-helpers/useQuery.ts
504
662
  // requires: npm i query-string
505
- import { usePage } from '@inertiajs/vue3';
663
+ import type { VisitOptions } from '@inertiajs/core';
664
+ import { router, usePage } from '@inertiajs/vue3';
506
665
  import qs, { type StringifyOptions } from 'query-string';
666
+ import { shallowRef } from 'vue';
667
+
668
+ /**
669
+ * How arrays sit in the query string, for every helper that reads or writes
670
+ * it: `tags=a,b`, what Spatie QueryBuilder parses. Change it here, once —
671
+ * a reader and a writer that disagree turn an array into a string.
672
+ */
673
+ export const QUERY_ARRAY_FORMAT = 'comma';
674
+
675
+ /** A URL's parts. The first `?` starts the query; a `#` ends it. */
676
+ export const splitUrl = (url: string) => {
677
+ const hashAt = url.indexOf('#');
678
+ const hash = hashAt < 0 ? '' : url.slice(hashAt);
679
+ const rest = hashAt < 0 ? url : url.slice(0, hashAt);
680
+ const queryAt = rest.indexOf('?');
681
+ return queryAt < 0
682
+ ? { path: rest, query: '', hash }
683
+ : { path: rest.slice(0, queryAt), query: rest.slice(queryAt + 1), hash };
684
+ };
685
+
686
+ /**
687
+ * The URL the last write is heading to, while its visit is in flight. Every
688
+ * reader and writer builds on it, so two writes before the server answers
689
+ * both land, and a write reads back at once. It counts only while `page.url`
690
+ * is still the one it was built on: any visit that lands supersedes it.
691
+ */
692
+ const pending = shallowRef<{ from: string; url: string } | null>(null);
693
+
694
+ /** `page.url`, or where the write in flight is taking it. */
695
+ export const currentUrl = (): string => {
696
+ const { url } = usePage();
697
+ return pending.value?.from === url ? pending.value.url : url;
698
+ };
699
+
700
+ const decodeKey = (raw: string): string => {
701
+ try {
702
+ return decodeURIComponent(raw.replace(/\+/g, ' '));
703
+ } catch {
704
+ return raw;
705
+ }
706
+ };
707
+
708
+ /**
709
+ * `url` with `changes` written in. A nullish, empty-string or empty-array
710
+ * value removes its key. Every other parameter is kept as it was written, in
711
+ * its place: a write never re-encodes or re-orders what it does not own.
712
+ */
713
+ export const withQuery = (
714
+ url: string,
715
+ changes: Record<string, unknown>
716
+ ): string => {
717
+ const { path, query, hash } = splitUrl(url);
718
+ const encoded = (key: string): string[] => {
719
+ const value = changes[key];
720
+ if (value == null || value === '') return [];
721
+ if (Array.isArray(value) && value.length === 0) return [];
722
+ return [
723
+ qs.stringify({ [key]: value }, { arrayFormat: QUERY_ARRAY_FORMAT }),
724
+ ];
725
+ };
726
+ const written = new Set<string>();
727
+ const parts = query
728
+ .split('&')
729
+ .filter(Boolean)
730
+ .flatMap((part) => {
731
+ const key = decodeKey(part.split('=')[0] ?? '');
732
+ if (!(key in changes)) return [part];
733
+ if (written.has(key)) return [];
734
+ written.add(key);
735
+ return encoded(key);
736
+ });
737
+ Object.keys(changes)
738
+ .filter((key) => !written.has(key))
739
+ .forEach((key) => parts.push(...encoded(key)));
740
+ return `${path}${parts.length ? `?${parts.join('&')}` : ''}${hash}`;
741
+ };
742
+
743
+ /**
744
+ * Takes the page to `url`: an Inertia visit, or with `server: false` a URL
745
+ * write with no request. Readers see `url` from now until the visit finishes.
746
+ */
747
+ export const visitUrl = (
748
+ url: string,
749
+ options: Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>,
750
+ server = true
751
+ ): void => {
752
+ const from = usePage().url;
753
+ const write = { from, url };
754
+ pending.value = write;
755
+ const settle = () => {
756
+ if (pending.value === write) pending.value = null;
757
+ };
758
+ if (server) {
759
+ let started = false;
760
+ router.visit(url, {
761
+ ...options,
762
+ onStart: (visit) => {
763
+ started = true;
764
+ options.onStart?.(visit);
765
+ },
766
+ onFinish: (visit) => {
767
+ settle();
768
+ options.onFinish?.(visit);
769
+ },
770
+ });
771
+ // A visit a `before` listener refused never starts, and never finishes.
772
+ if (!started) settle();
773
+ return;
774
+ }
775
+ const { preserveState, preserveScroll, replace } = options;
776
+ const client = { url, preserveState, preserveScroll, onFinish: settle };
777
+ if (replace) router.replace(client);
778
+ else router.push(client);
779
+ };
780
+
781
+ /** The query of `url` as an object, the way the server reads it. */
782
+ export const parseQuery = (
783
+ url: string,
784
+ arrayFormat: StringifyOptions['arrayFormat'] = QUERY_ARRAY_FORMAT
785
+ ): Record<string, unknown> => {
786
+ const { query } = splitUrl(url);
787
+ if (!query) return {};
788
+ // `qs.parse` decodes once. An encoded comma separates too: Laravel's pager
789
+ // links write `a%2Cb`, which PHP decodes to `a,b` before Spatie splits it.
790
+ return qs.parse(
791
+ arrayFormat === 'comma' ? query.replace(/%2C/gi, ',') : query,
792
+ { arrayFormat }
793
+ );
794
+ };
507
795
 
508
796
  export const useQuery = ({
509
- arrayFormat = 'comma',
797
+ arrayFormat = QUERY_ARRAY_FORMAT,
510
798
  }: { arrayFormat?: StringifyOptions['arrayFormat'] } = {}): Record<
511
799
  string,
512
800
  unknown
513
- > => {
514
- const { url } = usePage();
515
- const [, queryString] = url.split('?');
516
- if (!queryString) return {};
517
- return qs.parse(decodeURIComponent(queryString), {
518
- arrayFormat,
519
- decode: true,
520
- });
521
- };
801
+ > => parseQuery(currentUrl(), arrayFormat);
522
802
  ```
523
803
 
524
804
  **Don't swap `query-string` for a hand-rolled `URLSearchParams` reader.** It
@@ -535,14 +815,19 @@ assume the parsed-array shape. Install the dependency.
535
815
  ### useRouteQuery
536
816
 
537
817
  A single query parameter as a **writable ref**. Reading tracks the current
538
- URL; writing merges the parameter and performs a (replace) visit, so the
539
- server re-renders with the new value. Writing the current value is a no-op —
540
- safe to sync from server responses without visit loops.
818
+ URL (a repeated key reads its last value, as PHP does); writing merges the
819
+ parameter and performs a (replace) visit, so the server re-renders with the
820
+ new value. Writing the current value is a no-op — safe to sync from server
821
+ responses without visit loops. Writing `null` / `undefined` or the default
822
+ removes the key. Options, as on `useQueryObject`: `visitOptions` (Inertia's
823
+ `VisitOptions`, merged over `preserveState` / `preserveScroll` /
824
+ `replace: true`) and `server: false` for a URL write with no request.
541
825
 
542
826
  ```ts
543
827
  // inertia-helpers/useRouteQuery.ts
544
828
  // requires: npm i query-string
545
- import { router, usePage } from '@inertiajs/vue3';
829
+ import { currentUrl, splitUrl, visitUrl, withQuery } from './useQuery';
830
+ import type { VisitOptions } from '@inertiajs/core';
546
831
  import qs from 'query-string';
547
832
  import {
548
833
  computed,
@@ -554,8 +839,15 @@ import {
554
839
  type Options<T> = {
555
840
  /** Parse the raw string (e.g. `Number`). Defaults to identity. */
556
841
  transform?: (value: string) => T;
557
- preserveScroll?: boolean;
558
- preserveState?: boolean;
842
+ /** `false` writes the URL with no request. Default `true`. */
843
+ server?: boolean;
844
+ /**
845
+ * Merged over `{ preserveState, preserveScroll, replace: true }`. With
846
+ * `server: false` only those three apply.
847
+ */
848
+ visitOptions?: MaybeRefOrGetter<
849
+ Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>
850
+ >;
559
851
  };
560
852
 
561
853
  export function useRouteQuery<T = string>(
@@ -566,37 +858,34 @@ export function useRouteQuery<T = string>(
566
858
  ): WritableComputedRef<T> {
567
859
  const {
568
860
  transform = (value) => value as unknown as T,
569
- preserveScroll = true,
570
- preserveState = true,
861
+ server = true,
862
+ visitOptions,
571
863
  } = options;
572
- const page = usePage();
573
864
 
574
865
  const readRaw = (): string => {
575
- const [, queryString = ''] = page.url.split('?');
576
- const raw = qs.parse(queryString)[key];
577
- const single = Array.isArray(raw) ? raw[0] : raw;
866
+ const raw = qs.parse(splitUrl(currentUrl()).query)[key];
867
+ // A repeated key reads its last value, as PHP does.
868
+ const single = Array.isArray(raw) ? raw.at(-1) : raw;
578
869
  return single != null ? String(single) : toValue(defaultValue);
579
870
  };
580
871
 
581
872
  return computed<T>({
582
873
  get: () => transform(readRaw()),
583
874
  set: (next) => {
584
- const nextRaw = String(next);
875
+ // `null` / `undefined` mean "no value": the key leaves the URL.
876
+ const nextRaw = next == null ? toValue(defaultValue) : String(next);
585
877
  if (nextRaw === readRaw()) return;
586
-
587
- const [path = '', queryString = ''] = page.url.split('?');
588
- const query = qs.parse(queryString);
589
- if (nextRaw === toValue(defaultValue)) {
590
- delete query[key]; // keep URLs clean at the default
591
- } else {
592
- query[key] = nextRaw;
593
- }
594
- const stringified = qs.stringify(query);
595
- router.visit(stringified ? `${path}?${stringified}` : path, {
596
- preserveScroll,
597
- preserveState,
598
- replace: true,
599
- });
878
+ const value = nextRaw === toValue(defaultValue) ? null : nextRaw; // keep URLs clean at the default
879
+ visitUrl(
880
+ withQuery(currentUrl(), { [key]: value }),
881
+ {
882
+ preserveScroll: true,
883
+ preserveState: true,
884
+ replace: true,
885
+ ...toValue(visitOptions),
886
+ },
887
+ server
888
+ );
600
889
  },
601
890
  });
602
891
  }
@@ -605,103 +894,120 @@ export function useRouteQuery<T = string>(
605
894
  ### useQueryObject
606
895
 
607
896
  The **filter engine**: watch a filters object and mirror its non-empty
608
- values into the query string with an Inertia visit per change. Keys it has
609
- seen are cleaned up when they empty out; the `page` parameter resets on
610
- every filter change so results are never hidden on an out-of-range page.
897
+ values into the query string with one Inertia visit per change. Keys it has
898
+ seen are cleaned up when they empty out; keys it does not own (a tab, a sort)
899
+ are kept. Every change also removes `resetKeys` (default `['page']`), so
900
+ results are never hidden on an out-of-range page.
901
+
902
+ | Option | Default | What it does |
903
+ | -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
904
+ | `visitOptions` | — | Inertia's own `VisitOptions` (`only`, `headers`, `reset`, callbacks…), merged over `preserveState` / `preserveScroll` / `replace: true` |
905
+ | `debounce` | `{}` | Per-key wait in ms for typed keys: `{ search: 300 }` |
906
+ | `resetKeys` | `['page']` | Keys removed on every change. Passing it **replaces** the default: `['page', 'lots_page']`, or `[]` to keep the page |
907
+ | `paused` | `false` | While `true`, track changes but send nothing; resuming sends the current state once (see below) |
908
+ | `server` | `true` | `false` writes the URL with no request (`router.replace` / `push`); `visitOptions` then takes only `preserveState` / `preserveScroll` / `replace` |
909
+
910
+ Arrays are written in the one `QUERY_ARRAY_FORMAT` of `useQuery.ts` (`comma`:
911
+ `tags=a,b`, what Spatie QueryBuilder parses), the same one `useQuery` reads.
912
+ To send server-shaped keys (`filter[status]`) or reshape values, pass a getter
913
+ that builds them: `useQueryObject(() => ({ 'filter[status]': f.status }))`.
611
914
 
612
915
  ```ts
613
916
  // inertia-helpers/useQueryObject.ts
614
917
  // requires: npm i query-string
615
- import { useQuery } from './useQuery';
616
- import { isEmpty, isNil } from './utils';
617
- import { router, usePage } from '@inertiajs/vue3';
618
- import qs, { type StringifyOptions } from 'query-string';
918
+ import {
919
+ QUERY_ARRAY_FORMAT,
920
+ currentUrl,
921
+ parseQuery,
922
+ visitUrl,
923
+ withQuery,
924
+ } from './useQuery';
925
+ import type { VisitOptions } from '@inertiajs/core';
926
+ import qs from 'query-string';
619
927
  import { onScopeDispose, toValue, watch, type MaybeRefOrGetter } from 'vue';
620
928
 
929
+ /** A server visit's options; the URL, method and data are the helper's. */
930
+ type ServerVisitOptions = Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>;
931
+ /** A URL-only write (`server: false`) has no request to configure. */
932
+ type ClientVisitOptions = Pick<
933
+ VisitOptions,
934
+ 'preserveState' | 'preserveScroll' | 'replace'
935
+ >;
936
+
621
937
  type Options<T> = {
622
- transform?: (value: Partial<T>) => Partial<T>;
623
- /** Reset the `page` parameter on change. Default true. */
624
- resetPage?: boolean;
625
- /** Additional query keys removed whenever the object changes. */
938
+ /**
939
+ * Query keys removed on every change. Default `['page']`, so a filter
940
+ * change never strands the list on a page past its new end. Passing it
941
+ * REPLACES the default: name `page` too when it should still reset.
942
+ */
626
943
  resetKeys?: string[];
627
- /** Inertia partial reload prop names (supports reactive getter). */
628
- only?: MaybeRefOrGetter<string[]>;
629
944
  /**
630
- * When it resolves to `false` the watcher keeps tracking keys but skips
631
- * the navigation — use it when another owner performs the visit during a
632
- * programmatic transition, to avoid competing self-cancelling requests.
945
+ * While it resolves to `true`, changes are tracked but no visit is sent.
946
+ * When it goes back to `false`, the object's current state is sent once,
947
+ * if the URL does not already hold it. See the guide for when to use it.
633
948
  */
634
- shouldNavigate?: MaybeRefOrGetter<boolean>;
949
+ paused?: MaybeRefOrGetter<boolean>;
635
950
  /**
636
951
  * Keys whose changes wait this many ms before the visit, e.g. a search
637
952
  * box: `{ search: 300 }`. A change to any other key visits at once and
638
953
  * carries the waiting value with it, so nothing typed is lost.
639
954
  */
640
955
  debounce?: Partial<Record<keyof T & string, number>>;
641
- arrayFormat?: StringifyOptions['arrayFormat'];
642
- preserveState?: boolean;
643
- preserveScroll?: boolean;
644
- replace?: boolean;
645
- };
956
+ } & (
957
+ | {
958
+ /** Default `true`: every change is an Inertia visit. */
959
+ server?: true;
960
+ /** Merged over `{ preserveState, preserveScroll, replace: true }`. */
961
+ visitOptions?: MaybeRefOrGetter<ServerVisitOptions>;
962
+ }
963
+ | {
964
+ /** `false` writes the URL with no request (`router.replace` / `push`). */
965
+ server: false;
966
+ visitOptions?: MaybeRefOrGetter<ClientVisitOptions>;
967
+ }
968
+ );
646
969
 
647
970
  export function useQueryObject<T extends Record<string, unknown>>(
648
971
  object: MaybeRefOrGetter<T>,
649
972
  options: Options<T> = {}
650
973
  ): void {
651
974
  const {
652
- transform,
653
- resetPage = true,
654
- resetKeys = [],
655
- preserveState = true,
656
- preserveScroll = true,
657
- replace = true,
658
- only: onlyOption = [],
659
- shouldNavigate = true,
975
+ resetKeys = ['page'],
976
+ paused = false,
660
977
  debounce = {},
661
- arrayFormat = 'comma',
978
+ server = true,
979
+ visitOptions,
662
980
  } = options;
663
981
 
664
- const filterValidValues = (obj: T): Partial<T> => {
665
- const filtered: Partial<T> = {};
666
- Object.entries(obj).forEach(([key, val]) => {
667
- if (!isNil(val) && val !== '') {
668
- (filtered as Record<string, unknown>)[key] = val;
669
- }
670
- });
671
- return filtered;
672
- };
673
-
674
982
  /** Every key this composable has ever managed, for stable URL cleanup. */
675
983
  const seenKeys = new Set<string>(Object.keys(toValue(object) ?? {}));
676
984
 
677
- const navigate = (newObject: T) => {
678
- const validValues = filterValidValues(newObject);
679
- const transformedValues = transform ? transform(validValues) : validValues;
680
-
681
- // Start from the existing query to preserve keys owned elsewhere.
682
- const existingQuery = useQuery({ arrayFormat });
683
- seenKeys.forEach((key) => delete existingQuery[key]);
684
- if (resetPage) delete existingQuery.page;
685
- resetKeys.forEach((key) => delete existingQuery[key]);
686
-
687
- Object.entries(transformedValues).forEach(([key, val]) => {
688
- if (!isEmpty(val)) existingQuery[key] = val;
689
- });
690
-
691
- const stringified = qs.stringify(existingQuery, { arrayFormat });
692
- const newUrl = stringified
693
- ? [window.location.pathname, stringified].join('?')
694
- : window.location.pathname;
985
+ /** A query in one canonical form, so encoding and key order never count. */
986
+ const canonical = (url: string) =>
987
+ qs.stringify(parseQuery(url), { arrayFormat: QUERY_ARRAY_FORMAT });
695
988
 
696
- if (newUrl === usePage().url) return;
697
- if (!toValue(shouldNavigate)) return;
698
-
699
- router.visit(newUrl, {
700
- preserveState,
701
- preserveScroll,
702
- only: toValue(onlyOption),
703
- replace,
704
- });
989
+ const navigate = (newObject: T) => {
990
+ if (toValue(paused)) return;
991
+
992
+ // Only the reset keys and the keys this object owns change; every
993
+ // other parameter (a tab, a sort) stays as it is.
994
+ const changes: Record<string, unknown> = {};
995
+ resetKeys.forEach((key) => (changes[key] = null));
996
+ seenKeys.forEach((key) => (changes[key] = newObject[key]));
997
+ const from = currentUrl();
998
+ const url = withQuery(from, changes);
999
+ if (canonical(url) === canonical(from)) return;
1000
+
1001
+ visitUrl(
1002
+ url,
1003
+ {
1004
+ preserveState: true,
1005
+ preserveScroll: true,
1006
+ replace: true,
1007
+ ...toValue(visitOptions),
1008
+ },
1009
+ server
1010
+ );
705
1011
  };
706
1012
 
707
1013
  /** Each key's value as last seen, to tell which keys a change touched. */
@@ -714,8 +1020,8 @@ export function useQueryObject<T extends Record<string, unknown>>(
714
1020
  onScopeDispose(() => clearTimeout(pending));
715
1021
 
716
1022
  watch(
717
- () => toValue(object),
718
- (newObject) => {
1023
+ [() => toValue(object), () => toValue(paused)],
1024
+ ([newObject, isPaused], [, wasPaused]) => {
719
1025
  if (!newObject) return;
720
1026
 
721
1027
  Object.keys(newObject).forEach((key) => {
@@ -734,12 +1040,15 @@ export function useQueryObject<T extends Record<string, unknown>>(
734
1040
  );
735
1041
  lastSeen = now;
736
1042
  clearTimeout(pending);
1043
+ if (isPaused) return;
1044
+ // Resuming sends what changed while paused, without waiting.
1045
+ const resumed = wasPaused && !isPaused;
737
1046
  const waits = changed.map(
738
1047
  (key) => (debounce as Record<string, number | undefined>)[key] ?? 0
739
1048
  );
740
1049
  // Only debounced keys changed: wait for the typing to settle, then
741
1050
  // visit with whatever the object holds by then.
742
- if (changed.length && waits.every((ms) => ms > 0)) {
1051
+ if (!resumed && changed.length && waits.every((ms) => ms > 0)) {
743
1052
  pending = setTimeout(
744
1053
  () => navigate(toValue(object)),
745
1054
  Math.max(...waits)
@@ -753,7 +1062,8 @@ export function useQueryObject<T extends Record<string, unknown>>(
753
1062
  * into one watcher run, so a `clearFilters()` that blanks three keys
754
1063
  * fires ONE visit instead of three (measured: 3 writes → 3 runs under
755
1064
  * `sync`, 1 under the default). The two it saves used to self-cancel,
756
- * but they were still sent.
1065
+ * but they were still sent. Watching `paused` in the same source means
1066
+ * resuming and the change that comes with it are one run, one visit.
757
1067
  *
758
1068
  * This does NOT make typing cheap on its own — one visit per settled
759
1069
  * keystroke is still one visit per keystroke. `debounce` is for that.
@@ -766,7 +1076,10 @@ export function useQueryObject<T extends Record<string, unknown>>(
766
1076
  ```ts
767
1077
  // A listing page's whole filter wiring:
768
1078
  const filters = reactive({ search: '', status: null as string | null });
769
- useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
1079
+ useQueryObject(filters, {
1080
+ visitOptions: { only: ['invoices'] },
1081
+ debounce: { search: 300 },
1082
+ });
770
1083
  // Bind BbTextInput / BbSelectPopover to `filters.*` — every change becomes a
771
1084
  // partial reload and the server re-renders the `invoices` prop.
772
1085
  ```
@@ -775,9 +1088,9 @@ useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
775
1088
  > their own URL writers (the `page` / `perPage` refs of
776
1089
  > `usePaginatedResponseContext`, or `useRouteQuery`). Put the page ref in the
777
1090
  > object too and a pager click visits twice: once through the ref, once
778
- > through this watcher. And `resetPage` stops working: `navigate()` deletes
779
- > `page` from the query, then writes the object's value back, so a filter
780
- > change keeps the old page, possibly past the last one.
1091
+ > through this watcher. And the page reset stops working: `navigate()`
1092
+ > deletes `page` from the query, then writes the object's value back, so a
1093
+ > filter change keeps the old page, possibly past the last one.
781
1094
 
782
1095
  > **Typing: debounce the key, not the page.** Without `debounce`, "an
783
1096
  > Inertia visit per change" is literal: a text field bound straight to
@@ -787,16 +1100,63 @@ useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
787
1100
  >
788
1101
  > ```ts
789
1102
  > const filters = reactive({ search: '', status: null as string | null });
790
- > useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
1103
+ > useQueryObject(filters, {
1104
+ > visitOptions: { only: ['invoices'] },
1105
+ > debounce: { search: 300 },
1106
+ > });
791
1107
  > ```
792
1108
  >
793
1109
  > Typing in the search waits 300 ms of quiet; picking a status visits at once
794
1110
  > and takes the typed text with it. Keys not named in `debounce` never wait.
795
- >
796
- > **Programmatic multi-key writes** (reset, apply-a-saved-view): gate them
797
- > with `shouldNavigate` and perform the one visit yourself once the object is
798
- > settled. That is what the option is for: the watcher keeps tracking keys
799
- > for URL cleanup while the navigation is yours.
1111
+
1112
+ > **Programmatic multi-key writes need nothing.** A reset or an
1113
+ > apply-a-saved-view that writes several keys in one tick is already one
1114
+ > visit: the watcher runs once per tick. Do not reach for `paused` for this.
1115
+
1116
+ #### `paused`: when another visit owns the page for a while
1117
+
1118
+ Use it when something else sends a visit that also changes keys this object
1119
+ mirrors, and that visit takes time: a view switch whose own `router.get`
1120
+ rewrites the filters, a wizard step that navigates and seeds the next step's
1121
+ filters. Without it, the object's watcher fires on the next tick with the
1122
+ seeded values and sends a second visit; Inertia cancels the first (the one
1123
+ that mattered) in favour of it.
1124
+
1125
+ ```ts
1126
+ const switching = ref(false);
1127
+ useQueryObject(filters, {
1128
+ visitOptions: { only: ['conversations'] },
1129
+ paused: switching,
1130
+ });
1131
+
1132
+ const switchView = (view: string) => {
1133
+ switching.value = true;
1134
+ Object.assign(filters, filtersFor(view)); // tracked, not sent
1135
+ router.get(
1136
+ `/inbox/${view}`,
1137
+ {},
1138
+ {
1139
+ onFinish: () => (switching.value = false), // also runs on cancel
1140
+ }
1141
+ );
1142
+ };
1143
+ ```
1144
+
1145
+ What happens, exactly:
1146
+
1147
+ - **While `paused` is `true`**, every change is tracked (key cleanup, the
1148
+ debounce bookkeeping) and nothing is sent. A debounced change already
1149
+ waiting when the pause starts is dropped, not sent late.
1150
+ - **When it goes back to `false`**, the object's _current_ state is sent once,
1151
+ without waiting for a debounce. If the URL already holds that state — the
1152
+ other visit landed exactly these filters, the usual case — nothing is sent.
1153
+ Resuming and a change in the same tick are one visit.
1154
+ - It is a **span over time**, not a tick guard: set and cleared in the same
1155
+ tick, it pauses nothing (the watcher runs after the tick, when it is already
1156
+ `false`). Clear it in the competing visit's `onFinish`, which Inertia also
1157
+ calls when that visit is cancelled or fails, so the object never stays
1158
+ paused.
1159
+ - It accepts a ref or a getter: `paused: () => form.processing`.
800
1160
 
801
1161
  ---
802
1162
 
@@ -807,31 +1167,50 @@ counterpart of the component-state context
807
1167
  [`useBbTableContext`](../composables/useBbTableContext.md). Rule of thumb:
808
1168
  `useBbTableContext` mirrors what the _table widget_ is doing (selection,
809
1169
  sort, highlight); these contexts own what the _server sent_ (items, meta)
810
- and survive being read from any component on the page. First caller creates
811
- the context, later callers join it — no provider component required.
1170
+ and survive being read from any component on the page. The first caller
1171
+ creates the context and every later caller **below it** joins it — no
1172
+ provider component, but siblings (a table and a bulk bar) share one only
1173
+ when a common parent (the page) calls it first; each would otherwise create
1174
+ its own.
812
1175
 
813
1176
  ### usePaginatedResponseContext
814
1177
 
815
1178
  Wraps a Laravel paginated prop (`PaginatedResponse<T>`) into a shared
816
- context: `items` (editable until the prop changes), `totalItems`,
817
- `totalPages`, and `page` / `perPage` refs that read what the server answered
818
- and visit when written (`page` at 1 and `per_page` at the server's default
819
- leave the URL).
1179
+ context: `items` (a deep copy of the rows: `v-model` a row's field for inline
1180
+ editing without touching the page prop; editable until the prop changes),
1181
+ `totalItems`, `totalPages`, and `page` / `perPage` refs that read what the
1182
+ server answered and visit when written (`page` at 1 and `per_page` at the
1183
+ server's default leave the URL; a new `perPage` starts again from page 1). A
1184
+ missing or deferred prop reads as an empty page.
1185
+
1186
+ **When `items` resets.** A server visit that asks for this prop (a pager
1187
+ click, a filter, `router.reload({ only: ['users'] })`) always brings fresh
1188
+ rows, even identical ones: reloading after a failed delete undoes the
1189
+ optimistic edit. Anything else that swaps the prop — `router.appendToProp`
1190
+ on another prop rebuilds every prop, a reload of another prop, Back /
1191
+ Forward — keeps your edits unless the rows really changed. Anything past
1192
+ that (rolling back a failed edit, merging a concurrent change) is the app's.
820
1193
 
821
1194
  ```ts
822
1195
  // inertia-helpers/usePaginatedResponseContext.ts
823
- import { useRouteQuery } from './useRouteQuery';
1196
+ // requires: npm i query-string
1197
+ import { currentUrl, visitUrl, withQuery } from './useQuery';
824
1198
  import type { PaginatedResponse } from './types';
825
- import { isNotNil } from './utils';
826
- import type { PageProps } from '@inertiajs/core';
827
- import { usePage } from '@inertiajs/vue3';
1199
+ import { cloneDeep, isEqual, isNotNil } from './utils';
1200
+ import type { PageProps, VisitOptions } from '@inertiajs/core';
1201
+ import { router, usePage } from '@inertiajs/vue3';
828
1202
  import { injectLocal, provideLocal } from '@vueuse/core';
829
1203
  import {
830
1204
  computed,
1205
+ onMounted,
1206
+ onScopeDispose,
831
1207
  ref,
1208
+ shallowRef,
1209
+ toValue,
832
1210
  watch,
833
1211
  watchEffect,
834
1212
  type ComputedRef,
1213
+ type MaybeRefOrGetter,
835
1214
  type Ref,
836
1215
  } from 'vue';
837
1216
 
@@ -840,10 +1219,21 @@ export type PaginatedResponseContext<T> = {
840
1219
  perPage: Ref<number>;
841
1220
  page: Ref<number>;
842
1221
  totalPages: ComputedRef<number>;
843
- /** Current page items. Writable for optimistic edits; resets on navigation. */
1222
+ /** Current page items, a deep copy: edit it freely; resets on a new answer. */
844
1223
  items: Ref<T[]>;
845
1224
  };
846
1225
 
1226
+ export type PaginatedResponseOptions = {
1227
+ /** The query key the server reads the page from (Laravel's `pageName`). Default `'page'`. */
1228
+ pageQueryKey?: string;
1229
+ /** The query key the server reads the page size from. Default `'per_page'`. */
1230
+ perPageQueryKey?: string;
1231
+ /** Merged over `{ preserveState, preserveScroll }` and the history default (a page pushes, a size replaces). */
1232
+ visitOptions?: MaybeRefOrGetter<
1233
+ Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>
1234
+ >;
1235
+ };
1236
+
847
1237
  const toContextKey = (key: string): string => `${key}:paginatedResponse`;
848
1238
 
849
1239
  /**
@@ -851,30 +1241,40 @@ const toContextKey = (key: string): string => `${key}:paginatedResponse`;
851
1241
  * fields under `meta` (a resource collection, a hand-built shape) or at the
852
1242
  * top level (`->paginate()`, `->simplePaginate()`). `simplePaginate()` knows
853
1243
  * no total: the count is the rows so far, and the last page is the next one
854
- * while a next page exists.
1244
+ * while a next page exists, or the one before an empty page past the end.
855
1245
  */
856
1246
  export const readPagination = (paginated: PaginatedResponse<unknown>) => {
857
1247
  const fields = paginated.meta ?? paginated;
858
1248
  const current = fields.current_page ?? 1;
859
1249
  const perPage = fields.per_page ?? 10;
1250
+ const rows = paginated.data?.length ?? 0;
860
1251
  const links = paginated.links as { next?: string | null } | undefined;
861
1252
  const hasNext = !!(
862
1253
  fields.next_page_url ?? (Array.isArray(links) ? null : links?.next)
863
1254
  );
1255
+ const pastEnd = !rows && !hasNext && current > 1;
864
1256
  return {
865
1257
  current,
866
1258
  perPage,
867
- lastPage: fields.last_page ?? (hasNext ? current + 1 : current),
1259
+ lastPage:
1260
+ fields.last_page ??
1261
+ (hasNext ? current + 1 : pastEnd ? current - 1 : current),
868
1262
  total:
869
1263
  fields.total ??
870
- (current - 1) * perPage +
871
- (paginated.data?.length ?? 0) +
872
- (hasNext ? 1 : 0),
1264
+ (pastEnd ? 0 : (current - 1) * perPage + rows + (hasNext ? 1 : 0)),
873
1265
  };
874
1266
  };
875
1267
 
1268
+ const isCount = (value: number): boolean =>
1269
+ Number.isInteger(value) && value > 0;
1270
+
876
1271
  export const usePaginatedResponseContext = <T = object>(
877
- dataKey: string
1272
+ dataKey: string,
1273
+ {
1274
+ pageQueryKey = 'page',
1275
+ perPageQueryKey = 'per_page',
1276
+ visitOptions,
1277
+ }: PaginatedResponseOptions = {}
878
1278
  ): PaginatedResponseContext<T> => {
879
1279
  const existing = injectLocal<PaginatedResponseContext<T> | null>(
880
1280
  toContextKey(dataKey),
@@ -893,18 +1293,46 @@ export const usePaginatedResponseContext = <T = object>(
893
1293
  const inertiaPage = usePage<
894
1294
  PageProps & Record<string, PaginatedResponse<T>>
895
1295
  >();
1296
+ // A deferred, optional or not-yet-reloaded prop reads as an empty page.
896
1297
  const paginated = computed(
897
- () => inertiaPage.props[dataKey] as PaginatedResponse<T>
1298
+ (): PaginatedResponse<T> => inertiaPage.props[dataKey] ?? { data: [] }
898
1299
  );
899
1300
 
900
1301
  const pagination = computed(() => readPagination(paginated.value));
901
- const items = ref<T[]>(paginated.value.data ?? []) as Ref<T[]>;
1302
+ // A deep copy: inline edits (`v-model` on a row's field) stay off the page
1303
+ // prop, so the "rows really changed" check below compares two things.
1304
+ const items = ref<T[]>(cloneDeep(paginated.value.data ?? [])) as Ref<T[]>;
902
1305
  const totalItems = computed(() => pagination.value.total);
903
1306
  const totalPages = computed(() => pagination.value.lastPage);
904
1307
 
905
- // A new prop is a new answer (a visit, Back / Forward); a partial reload of
906
- // another prop keeps this one, and the edits made to `items`.
907
- watch(paginated, (fresh) => (items.value = fresh.data ?? []));
1308
+ // Server visits in flight that ask for this prop (a pager click, a filter,
1309
+ // `router.reload({ only: [dataKey] })`).
1310
+ const asking = new Set<unknown>();
1311
+ const asks = ({ only, except }: { only: string[]; except: string[] }) =>
1312
+ only.length ? only.includes(dataKey) : !except.includes(dataKey);
1313
+ const unbindStart = router.on('start', ({ detail: { visit } }) => {
1314
+ if (asks(visit)) asking.add(visit);
1315
+ });
1316
+ const unbindFinish = router.on('finish', ({ detail: { visit } }) => {
1317
+ asking.delete(visit);
1318
+ });
1319
+ onScopeDispose(() => {
1320
+ unbindStart();
1321
+ unbindFinish();
1322
+ });
1323
+
1324
+ // A visit that asked for this prop is a new answer: fresh rows, the edits
1325
+ // made to `items` go. Anything else that swaps the prop (`appendToProp` on
1326
+ // another prop rebuilds them all, Back / Forward) keeps the edits unless the
1327
+ // rows really changed. `sync`: the answer lands before its visit finishes.
1328
+ watch(
1329
+ paginated,
1330
+ (fresh, old) => {
1331
+ if (!asking.size && isEqual(fresh.data, old.data)) return;
1332
+ items.value = cloneDeep(fresh.data ?? []);
1333
+ },
1334
+ { flush: 'sync' }
1335
+ );
908
1336
 
909
1337
  // The server's default page size is what it answers a URL without
910
1338
  // `per_page`. Until such an answer arrives it is unknown (`''` matches no
@@ -913,24 +1341,74 @@ export const usePaginatedResponseContext = <T = object>(
913
1341
  const defaultPerPage = ref('');
914
1342
  watchEffect(() => {
915
1343
  const [, queryString = ''] = inertiaPage.url.split('?');
916
- if (!new URLSearchParams(queryString).has('per_page'))
1344
+ if (!new URLSearchParams(queryString).has(perPageQueryKey))
917
1345
  defaultPerPage.value = `${pagination.value.perPage}`;
918
1346
  });
919
- const pageQuery = useRouteQuery('page', '1');
920
- const perPageQuery = useRouteQuery('per_page', defaultPerPage);
1347
+
1348
+ // Where the last pager write is going, while its visit is in flight: a
1349
+ // second click before the answer builds on it ("next" twice is page 3).
1350
+ const target = shallowRef<{ url: string; page: number; perPage: number }>();
1351
+ const inFlight = () => {
1352
+ const write = target.value;
1353
+ return write?.url === currentUrl() && write.url !== inertiaPage.url
1354
+ ? write
1355
+ : undefined;
1356
+ };
1357
+ const go = (to: number, size?: number, extra?: { replace: true }) => {
1358
+ const url = withQuery(currentUrl(), {
1359
+ [pageQueryKey]: to === 1 ? null : to,
1360
+ ...(isNotNil(size) && {
1361
+ [perPageQueryKey]: `${size}` === defaultPerPage.value ? null : size,
1362
+ }),
1363
+ });
1364
+ target.value = { url, page: to, perPage: size ?? perPage.value };
1365
+ visitUrl(url, {
1366
+ preserveScroll: true,
1367
+ preserveState: true,
1368
+ // A page is a history entry (Back goes to the previous page); a new
1369
+ // size is a setting and replaces.
1370
+ replace: isNotNil(size),
1371
+ ...toValue(visitOptions),
1372
+ ...extra,
1373
+ });
1374
+ };
921
1375
 
922
1376
  // Both read the server's answer, not the URL: a clamped `?page=99`, a
923
1377
  // capped `?per_page=500` or a redirect to the bare URL shows what the
924
- // server sent. Writing one visits, and only when the URL changes.
1378
+ // server sent. Writing one visits when it changes what they show; a size
1379
+ // starts again from page 1. Junk (0, NaN, 2.5, past a known last page) is
1380
+ // ignored.
925
1381
  const page = computed({
926
- get: () => pagination.value.current,
927
- set: (next: number) => (pageQuery.value = `${next}`),
1382
+ get: () => inFlight()?.page ?? pagination.value.current,
1383
+ set: (next: number) => {
1384
+ const last = (paginated.value.meta ?? paginated.value).last_page;
1385
+ if (!isCount(next) || (isNotNil(last) && next > last)) return;
1386
+ if (next !== page.value) go(next);
1387
+ },
928
1388
  });
929
1389
  const perPage = computed({
930
- get: () => pagination.value.perPage,
931
- set: (next: number) => (perPageQuery.value = `${next}`),
1390
+ get: () => inFlight()?.perPage ?? pagination.value.perPage,
1391
+ set: (next: number) => {
1392
+ if (isCount(next) && next !== perPage.value) go(1, next);
1393
+ },
932
1394
  });
933
1395
 
1396
+ // Laravel's `paginate()` does not clamp: `?page=3` after page 3's last row
1397
+ // went answers an empty page 3 of 2. Go to the last page, once per answer.
1398
+ let clampedFrom = '';
1399
+ onMounted(() =>
1400
+ watch(
1401
+ pagination,
1402
+ ({ current, lastPage }) => {
1403
+ if (current <= lastPage || lastPage < 1) return;
1404
+ if (clampedFrom === inertiaPage.url) return;
1405
+ clampedFrom = inertiaPage.url;
1406
+ go(lastPage, undefined, { replace: true });
1407
+ },
1408
+ { immediate: true }
1409
+ )
1410
+ );
1411
+
934
1412
  const context: PaginatedResponseContext<T> = {
935
1413
  totalItems,
936
1414
  perPage,
@@ -963,6 +1441,38 @@ return Inertia::render('Admin/Users/Index', [
963
1441
  one while a next page exists) and `totalPages` ends at the next page, so a
964
1442
  pager shows "next" until the last page.
965
1443
 
1444
+ **`->cursorPaginate()` is not supported.** It has no page numbers (only
1445
+ `next_cursor` / `prev_cursor` links), so `page` would read 1 on every page
1446
+ and "next" would visit a `?page=2` Laravel ignores. Use `->paginate()` or
1447
+ `->simplePaginate()` with this context.
1448
+
1449
+ **A page is a history entry.** Writing `page` pushes, so Back goes to the
1450
+ previous page; a new `perPage` (a setting) and filter writes replace. Pass
1451
+ `visitOptions: { replace: true }` to keep the pager out of history.
1452
+
1453
+ **A page past the last goes to the last.** Laravel does not clamp: delete
1454
+ the last row of page 3 and `?page=3` answers an empty page 3 of 2. The
1455
+ context visits the last page once (`replace`); an empty `simplePaginate()`
1456
+ page past the first goes back one.
1457
+
1458
+ **Writes before the answer build on each other.** While a pager visit is in
1459
+ flight, `page` / `perPage` read where it is going, so "next" clicked twice
1460
+ lands two pages on, and a click right after a filter or size change keeps
1461
+ it. A write equal to what they show, a page that is not a positive integer,
1462
+ or a page past a known last page makes no visit.
1463
+
1464
+ Options (the second argument, also taken by `useTableContext`):
1465
+
1466
+ - `pageQueryKey` / `perPageQueryKey` — the query keys the server reads
1467
+ (defaults `'page'` / `'per_page'`). A paginator with its own page name,
1468
+ `->paginate(15, ['*'], 'lots_page')`, takes
1469
+ `usePaginatedResponseContext('lots', { pageQueryKey: 'lots_page' })` — and
1470
+ two tables on one page need one each.
1471
+ - `visitOptions` — merged over `{ preserveState, preserveScroll, replace:
1472
+ true }` on every pager visit, a ref or getter read each time. Without it a
1473
+ page change reloads every prop; `{ only: ['users'] }` reloads the table
1474
+ alone.
1475
+
966
1476
  ### useTableSelectionContext
967
1477
 
968
1478
  Shared selection state by key, in server terms: `selected` and `unselected`
@@ -971,6 +1481,12 @@ item lists plus the `all` flag ("select-all across pages" semantics — when
971
1481
  combines `all` + `unselected` instead). Bind these to `BbTable`'s
972
1482
  `v-model`, `v-model:unselected-items` and `v-model:select-all`.
973
1483
 
1484
+ The selection is never reset for you: it survives page changes (that is
1485
+ the cross-page feature) and filter changes alike, since the URL also holds
1486
+ state that has nothing to do with the table. When a bulk action must not
1487
+ reach rows the new filter hides, call `resetSelection()` where the filters
1488
+ change, e.g. `watch(filters, resetSelection, { deep: true })`.
1489
+
974
1490
  ```ts
975
1491
  // inertia-helpers/useTableSelectionContext.ts
976
1492
  import { isNotNil } from './utils';
@@ -1034,6 +1550,7 @@ builds on.
1034
1550
  import {
1035
1551
  usePaginatedResponseContext,
1036
1552
  type PaginatedResponseContext,
1553
+ type PaginatedResponseOptions,
1037
1554
  } from './usePaginatedResponseContext';
1038
1555
  import {
1039
1556
  useTableSelectionContext,
@@ -1048,7 +1565,9 @@ export type TableContext<T, U> = TableSelectionContext<U> &
1048
1565
  const toTableKey = (key: string): string => `${key}:table`;
1049
1566
 
1050
1567
  export const useTableContext = <T = object, U = T>(
1051
- dataKey: string
1568
+ dataKey: string,
1569
+ /** For the paginated half: query keys and pager visit options. */
1570
+ options?: PaginatedResponseOptions
1052
1571
  ): TableContext<T, U> => {
1053
1572
  const existing = injectLocal<TableContext<T, U> | null>(
1054
1573
  toTableKey(dataKey),
@@ -1058,7 +1577,7 @@ export const useTableContext = <T = object, U = T>(
1058
1577
 
1059
1578
  const context: TableContext<T, U> = {
1060
1579
  ...useTableSelectionContext<U>(dataKey),
1061
- ...usePaginatedResponseContext<T>(dataKey),
1580
+ ...usePaginatedResponseContext<T>(dataKey, options),
1062
1581
  };
1063
1582
 
1064
1583
  provideLocal(toTableKey(dataKey), context);
@@ -1070,7 +1589,10 @@ Gotchas:
1070
1589
 
1071
1590
  - The key is a rendezvous string (use the page-prop name, e.g.
1072
1591
  `'invoices'`). Like the library's table context, a typo doesn't error —
1073
- it silently creates a second, orphan context.
1592
+ it silently creates a second, orphan context. So does a sibling: call it
1593
+ in the page (or a parent of every reader) first.
1594
+ - The selection outlives a filter change (it is cross-page by design). Call
1595
+ `resetSelection()` where the filter changes when the old picks must go.
1074
1596
  - Each composite context provides under a namespaced key
1075
1597
  (`invoices:table`, `invoices:selection`, `invoices:paginatedResponse`),
1076
1598
  so the pieces can also be joined individually.
@@ -1104,9 +1626,14 @@ submit, chain a toast, or hand the promise to a `BbButton` `@click` /
1104
1626
  Inertia form — `form.errors`, `form.isDirty`, `form.reset()` all work.
1105
1627
 
1106
1628
  The promise resolves `true` when the server answered and `false` when the visit
1107
- was cancelled (a `before` listener such as `useConfirmOnRouteLeave`, `cancel()`)
1108
- or interrupted by a newer one: nothing was saved, so skip the success toast and
1109
- the re-baseline (`if (!(await put(url))) return;`) and the form stays dirty.
1629
+ was cancelled (its own `onBefore` returning false, a `before` listener such as
1630
+ `useConfirmOnRouteLeave`, `cancel()`) or interrupted by a newer one: nothing was
1631
+ saved, so skip the success toast and the re-baseline
1632
+ (`if (!(await put(url))) return;`) and the form stays dirty. A save the server
1633
+ answers with a redirect to a `#fragment` (Inertia's 409 follow-up visit) or with
1634
+ `Inertia::location()` resolves `true`; an Inertia-rendered error page (a 403 /
1635
+ 500 with your custom error component) rejects. Your own `onSuccess` / `onError`
1636
+ / `onFinish` options still run.
1110
1637
 
1111
1638
  **Declare the draft shape as a `type` alias, not an `interface`.** The generic
1112
1639
  is constrained to `Record<string, FormValue>` (what Inertia can serialize), and
@@ -1119,6 +1646,7 @@ TypeScript quirk, not a statement about your data.
1119
1646
  // inertia-helpers/usePromiseForm.ts
1120
1647
  import { useLoading } from './useLoading';
1121
1648
  import {
1649
+ router,
1122
1650
  useForm,
1123
1651
  type InertiaForm,
1124
1652
  type InertiaFormProps,
@@ -1136,6 +1664,17 @@ type FormValue =
1136
1664
 
1137
1665
  type Options = Parameters<InertiaFormProps<object>['get']>[1];
1138
1666
 
1667
+ // Inertia 3's per-visit failure callbacks. Inertia 2 has none (it ignores
1668
+ // them), only the global `invalid` / `exception` events, heard below.
1669
+ type FailureCallbacks = {
1670
+ onHttpException?: (response: { status: number }) => boolean | void;
1671
+ onNetworkError?: (error: unknown) => boolean | void;
1672
+ };
1673
+ const onRouterEvent = (
1674
+ name: string,
1675
+ listener: (event: CustomEvent) => void
1676
+ ): (() => void) => router.on(name as 'start', listener as never);
1677
+
1139
1678
  export const usePromiseForm = <T extends Record<string, FormValue>>(
1140
1679
  initialData: T
1141
1680
  ) => {
@@ -1150,34 +1689,129 @@ export const usePromiseForm = <T extends Record<string, FormValue>>(
1150
1689
  ): Promise<boolean> =>
1151
1690
  // true: the server answered and the save went through. false: the
1152
1691
  // visit was cancelled or interrupted, nothing to report. Rejects with
1153
- // the error bag, or for a response that is not Inertia's.
1154
- new Promise<boolean>((resolve, reject) => {
1155
- const { onFinish: userOnFinish, ...visitOptions } = options;
1692
+ // the error bag, or for an error page / a response that is not Inertia's.
1693
+ new Promise<boolean>((resolvePromise, rejectPromise) => {
1694
+ const {
1695
+ onBefore,
1696
+ onSuccess,
1697
+ onError,
1698
+ onHttpException,
1699
+ onNetworkError,
1700
+ onFinish,
1701
+ ...visitOptions
1702
+ } = options as NonNullable<Options> & FailureCallbacks;
1703
+ // Visits sent with these callbacks, and how many finished: a save
1704
+ // answered with a 409 redirect (a redirect with a #fragment) is
1705
+ // re-sent by Inertia itself with the same callbacks, and that
1706
+ // follow-up interrupts the first visit. The last one settles.
1707
+ let sent = 0;
1708
+ let finished = 0;
1709
+ let awaitingBefore = false;
1710
+ let failedStatus: number | null = null;
1711
+ // Inertia 2: a non-Inertia response or a network error is a global
1712
+ // event only, fired before the visit finishes. Heard while this
1713
+ // promise is open (an async reload failing meanwhile counts too).
1714
+ const unbindFailures = [
1715
+ onRouterEvent('invalid', ({ detail }) => {
1716
+ failedStatus = (detail as { response: { status: number } }).response
1717
+ .status;
1718
+ }),
1719
+ onRouterEvent('exception', () => void (failedStatus = 0)),
1720
+ ];
1721
+ const settle = () => {
1722
+ unbind();
1723
+ unbindFailures.forEach((unbindFailure) => unbindFailure());
1724
+ };
1725
+ const resolve = (value: boolean) => {
1726
+ settle();
1727
+ resolvePromise(value);
1728
+ };
1729
+ const reject = (error: unknown) => {
1730
+ settle();
1731
+ rejectPromise(error);
1732
+ };
1733
+ // A visit stopped before it starts (its own onBefore returning
1734
+ // false, below, or a `before` listener that refuses it) fires no
1735
+ // callback at all: no save, resolve false. Registered last, this
1736
+ // listener sees what the ones before it decided.
1737
+ const unbind = router.on('before', (event) => {
1738
+ if (!awaitingBefore) return;
1739
+ awaitingBefore = false;
1740
+ if (!event.defaultPrevented) return;
1741
+ // Held by useConfirmOnRouteLeave's dialog: "Discard changes"
1742
+ // re-sends it with these callbacks, "Keep editing" finishes it
1743
+ // as cancelled. Either one settles this promise.
1744
+ if (
1745
+ (event.detail.visit as { awaitingConfirm?: boolean })
1746
+ .awaitingConfirm
1747
+ )
1748
+ return;
1749
+ if (--sent === finished) resolve(false);
1750
+ });
1156
1751
 
1157
1752
  form[method](url, {
1158
1753
  ...visitOptions,
1159
- onSuccess: () => resolve(true),
1160
- onError: (errors: unknown) => reject(errors),
1754
+ onBefore: (visit) => {
1755
+ const answer = onBefore?.(visit);
1756
+ if (answer === false) {
1757
+ if (sent === finished) resolve(false);
1758
+ } else {
1759
+ sent++;
1760
+ awaitingBefore = true;
1761
+ }
1762
+ return answer;
1763
+ },
1764
+ onSuccess: (page) => {
1765
+ onSuccess?.(page);
1766
+ // An error page Inertia renders (a 403 / 500 with `x-inertia`)
1767
+ // lands like a success: it is not a save.
1768
+ if (failedStatus === null) resolve(true);
1769
+ else reject(new Error(`Inertia visit answered ${failedStatus}.`));
1770
+ },
1771
+ onError: (errors) => {
1772
+ onError?.(errors);
1773
+ reject(errors);
1774
+ },
1775
+ ...({
1776
+ onHttpException: (response) => {
1777
+ failedStatus = response.status;
1778
+ return onHttpException?.(response);
1779
+ },
1780
+ onNetworkError: (error) => {
1781
+ failedStatus = 0;
1782
+ return onNetworkError?.(error);
1783
+ },
1784
+ } satisfies FailureCallbacks),
1161
1785
  onFinish: (
1162
1786
  ...args: Parameters<NonNullable<NonNullable<Options>['onFinish']>>
1163
1787
  ) => {
1164
- userOnFinish?.(...args);
1165
- // A cancelled visit (`cancel()`, a `before` listener such as
1166
- // useConfirmOnRouteLeave's "Keep editing") or one a newer visit
1788
+ onFinish?.(...args);
1789
+ finished++;
1790
+ // A cancelled visit (`cancel()`) or one a newer visit
1167
1791
  // interrupted is no failure, and no save either: it reaches
1168
- // only onFinish. Resolve false.
1792
+ // only onFinish. Resolve false, unless it is Inertia's own
1793
+ // 409 follow-up that interrupted it: that one settles.
1169
1794
  const [visit] = args as unknown as [
1170
1795
  { cancelled?: boolean; interrupted?: boolean } | undefined,
1171
1796
  ];
1172
1797
  if (visit?.cancelled || visit?.interrupted) {
1173
- resolve(false);
1798
+ if (sent <= finished) resolve(false);
1174
1799
  return;
1175
1800
  }
1176
1801
  // A non-Inertia response (e.g. a 423 entity lock or a 419
1177
- // CSRF mismatch) fires neither onSuccess nor onError, only
1178
- // onFinish. Settle here so isSubmitting is released instead
1179
- // of hanging the form forever. No-op once already settled.
1180
- reject(new Error('Inertia visit did not return a valid response.'));
1802
+ // CSRF mismatch) or a network error fires neither onSuccess
1803
+ // nor onError, only onFinish. Settle here so isSubmitting is
1804
+ // released instead of hanging the form forever. No-op once
1805
+ // already settled.
1806
+ if (failedStatus !== null) {
1807
+ reject(
1808
+ new Error('Inertia visit did not return a valid response.')
1809
+ );
1810
+ return;
1811
+ }
1812
+ // `Inertia::location()` (an external redirect after the save)
1813
+ // fires only onFinish too, while the browser leaves.
1814
+ resolve(true);
1181
1815
  },
1182
1816
  });
1183
1817
  })
@@ -1207,6 +1841,26 @@ export const usePromiseForm = <T extends Record<string, FormValue>>(
1207
1841
  > have nothing else to do on rejection. Every call site in this guide and its
1208
1842
  > recipes follows one of those two shapes — copy them, not a bare `post(...)`.
1209
1843
 
1844
+ **A summary line over the form is the page's, not the helper's.** The
1845
+ rejection tells the two failures apart: the error bag (an object, already in
1846
+ `form.errors`) or an `Error` (the server answered with something that is not
1847
+ Inertia's). Word them in the `catch`:
1848
+
1849
+ ```ts
1850
+ const failure = ref('');
1851
+ const save = async () => {
1852
+ failure.value = '';
1853
+ try {
1854
+ await put(`/invoices/${invoice.id}`);
1855
+ } catch (error) {
1856
+ failure.value =
1857
+ error instanceof Error
1858
+ ? 'The save did not go through. Try again.'
1859
+ : 'Check the highlighted fields.';
1860
+ }
1861
+ };
1862
+ ```
1863
+
1210
1864
  **Seeding a draft: `NullableKeys<T>`, not a hand-rolled `| null` on every
1211
1865
  field.** Don't declare a bespoke `Draft` type that bolts `| null` onto each
1212
1866
  string prop by hand — that's what produces empty-string drafts (`''`
@@ -1251,57 +1905,86 @@ const { form } = usePromiseForm<NullableKeys<InvoiceRequest>>({
1251
1905
 
1252
1906
  Deep dirty-checking against a snapshot baseline — for edit screens whose
1253
1907
  state is not a single Inertia form (multiple forms, ad-hoc refs). For a
1254
- single `usePromiseForm`, prefer the built-in `form.isDirty`.
1908
+ single `usePromiseForm`, prefer the built-in `form.isDirty`. The baseline is
1909
+ a structural hash of each value, so Dates, Sets, Maps, `undefined` and NaN
1910
+ compare as they are, and a `File` by its name, size, type and date, so picking
1911
+ another file over a picked one counts.
1255
1912
 
1256
1913
  ```ts
1257
1914
  // inertia-helpers/useHasChanged.ts
1258
- import { isEqual } from './utils';
1915
+ import { isPlainObject } from './utils';
1916
+ import { hash } from 'object-code';
1259
1917
  import { computed, ref, toValue, type WatchSource } from 'vue';
1260
1918
 
1261
1919
  export function useHasChanged<T extends WatchSource<unknown>[]>(
1262
1920
  dependencies: T
1263
1921
  ) {
1264
1922
  /*
1265
- * Baselines are JSON round-trips, NOT `structuredClone`. This composable's
1266
- * documented inputs are refs and reactive objects, and `structuredClone`
1267
- * throws `DataCloneError` on every one of them — a Vue proxy is not a
1268
- * structured-cloneable type. It took the page down at setup.
1269
- *
1270
- * The comparison needs no copy at all: `isEqual` is hash-based and reads
1271
- * straight through a proxy, so the live value can be hashed as-is. Only the
1272
- * BASELINE needs detaching from live state, which is what the round-trip is
1273
- * for.
1923
+ * The baseline is a structural HASH, not a copy. A copy has to survive the
1924
+ * values a form holds: a JSON round-trip turns a Date into a string, a File,
1925
+ * Set or Map into `{}`, drops `undefined` (and throws on a bare one), so
1926
+ * an untouched form read as changed; `structuredClone` throws on Vue proxies.
1927
+ * `object-code` hashes straight through a proxy and keeps Dates (by time),
1928
+ * Sets / Maps (by entries), `undefined` and NaN apart, and a number is all
1929
+ * the baseline needs to hold. It hashes every File alike (no own keys), so
1930
+ * a File is read by name, size, type and date first: picking another file
1931
+ * over a picked one counts as a change.
1274
1932
  */
1275
1933
  const snapshot = (dep: WatchSource<unknown>) =>
1276
- JSON.parse(JSON.stringify(toValue(dep)));
1934
+ hash(withFileFingerprints(toValue(dep)));
1277
1935
 
1278
- const baselineValues = ref<unknown[]>(dependencies.map(snapshot));
1936
+ const baselines = ref<number[]>(dependencies.map(snapshot));
1279
1937
 
1280
1938
  /** Re-baseline (call after a successful save). */
1281
1939
  const takeSnapshot = (): void => {
1282
- baselineValues.value = dependencies.map(snapshot);
1940
+ baselines.value = dependencies.map(snapshot);
1283
1941
  };
1284
1942
 
1285
1943
  const hasChanged = computed(() =>
1286
- dependencies.some(
1287
- (dep, index) => !isEqual(toValue(dep), baselineValues.value[index])
1288
- )
1944
+ dependencies.some((dep, index) => snapshot(dep) !== baselines.value[index])
1289
1945
  );
1290
1946
 
1291
1947
  return { hasChanged, takeSnapshot };
1292
1948
  }
1949
+
1950
+ const withFileFingerprints = (value: unknown): unknown =>
1951
+ value instanceof Blob
1952
+ ? [
1953
+ value.size,
1954
+ value.type,
1955
+ (value as Partial<File>).name,
1956
+ (value as Partial<File>).lastModified,
1957
+ ]
1958
+ : Array.isArray(value)
1959
+ ? value.map(withFileFingerprints)
1960
+ : isPlainObject(value)
1961
+ ? Object.fromEntries(
1962
+ Object.entries(value).map(([key, item]) => [
1963
+ key,
1964
+ withFileFingerprints(item),
1965
+ ])
1966
+ )
1967
+ : value;
1293
1968
  ```
1294
1969
 
1295
1970
  ### useConfirmOnRouteLeave
1296
1971
 
1297
1972
  A dirty-state navigation guard: intercepts Inertia visits and asks through
1298
1973
  [`useConfirm`](../composables/useConfirm.md) before leaving. Requires
1299
- `<BbHost />` mounted in the app root. Server redirects after a
1300
- whitelisted action carry the `X-Whitelisted-Action` header so a saving
1301
- visit's redirect is never blocked by its own guard.
1974
+ `<BbHost />` mounted in the app root. `whitelist` names the form's own
1975
+ submit targets: a submit (POST / PUT / PATCH / DELETE) to one of them goes
1976
+ out without asking, while a plain link to the same path (a Cancel button
1977
+ back to `/invoices`) still asks. Server redirects after a whitelisted
1978
+ action carry the `X-Whitelisted-Action` header so a saving visit's redirect
1979
+ is never blocked by its own guard. Leaving the app (closing the tab,
1980
+ reloading, a typed URL, a plain `<a>`) gets the browser's own "Leave site?"
1981
+ prompt while the guard is armed; a whitelisted save that the server answers
1982
+ with `Inertia::location()` leaves without one. `stack` picks the `BbDialog`
1983
+ stack the dialog joins; `variant` defaults to `'destructive'`.
1302
1984
 
1303
1985
  ```ts
1304
1986
  // inertia-helpers/useConfirmOnRouteLeave.ts
1987
+ import { useHelperMessages } from './messages';
1305
1988
  import { router } from '@inertiajs/vue3';
1306
1989
  import { useConfirm, type BbConfirmMessage } from 'bitboss-ui';
1307
1990
  import { onScopeDispose, toValue, type MaybeRefOrGetter } from 'vue';
@@ -1315,7 +1998,11 @@ type Visit = {
1315
1998
  };
1316
1999
 
1317
2000
  type Options = {
1318
- /** URLs that bypass the dialog (e.g. the form's own submit target). */
2001
+ /**
2002
+ * Submit targets that bypass the dialog (the form's own save). Only a
2003
+ * submit (POST / PUT / PATCH / DELETE) passes: a link to the same path
2004
+ * (Cancel → the list) still asks.
2005
+ */
1319
2006
  whitelist?: MaybeRefOrGetter<string[]>;
1320
2007
  /** Disable the guard (bind to `!hasChanged` / `!form.isDirty`). */
1321
2008
  disabled?: MaybeRefOrGetter<boolean>;
@@ -1327,12 +2014,14 @@ type Options = {
1327
2014
  text?: string;
1328
2015
  /**
1329
2016
  * The dialog's buttons and look, BbConfirm's own options: a label string
1330
- * or a full button config. Defaults: `'Discard changes'` / `'Keep editing'`,
1331
- * `variant: 'destructive'`. Pass translated labels in a non-English app.
2017
+ * or a full button config. Default words: `leave` in `messages.ts`, in the
2018
+ * library's `locale`; `variant: 'destructive'`.
1332
2019
  */
1333
2020
  yes?: BbConfirmMessage['yes'];
1334
2021
  no?: BbConfirmMessage['no'];
1335
2022
  variant?: BbConfirmMessage['variant'];
2023
+ /** The `BbDialog` stack the dialog joins (BbConfirm's `stack`). */
2024
+ stack?: BbConfirmMessage['stack'];
1336
2025
  };
1337
2026
 
1338
2027
  const normalizeUrl = (value: string | URL) => {
@@ -1359,24 +2048,26 @@ export const useConfirmOnRouteLeave = ({
1359
2048
  disabled = false,
1360
2049
  onBefore = () => true,
1361
2050
  onLeave = () => true,
1362
- title = 'Unsaved changes',
1363
- text = 'You have unsaved changes. Leave without saving?',
1364
- yes = 'Discard changes',
1365
- no = 'Keep editing',
2051
+ title,
2052
+ text,
2053
+ yes,
2054
+ no,
1366
2055
  variant = 'destructive',
2056
+ stack,
1367
2057
  }: Options = {}) => {
1368
2058
  // Captured in setup so the built-in dialog is owned by the guarded page:
1369
2059
  // if the page unmounts anyway, the dialog dismisses itself quietly.
1370
2060
  const { confirm } = useConfirm();
2061
+ const messages = useHelperMessages();
1371
2062
  let bypassNextVisit = false;
1372
2063
 
1373
2064
  const unbind = router.on('before', (event) => {
1374
2065
  const visit = event.detail.visit as Visit;
1375
2066
 
1376
2067
  // A hover / mount prefetch (`<Link prefetch>`, `<BbButton href prefetch>`)
1377
- // fires `before` too, but leaves nothing: no dialog, and it must not use up
1378
- // the bypass the re-sent visit below is waiting for.
1379
- if (visit.prefetch) return;
2068
+ // fires `before` too, but leaves nothing: no dialog. A visit another
2069
+ // guard on the page already stopped is that guard's to ask about.
2070
+ if (visit.prefetch || event.defaultPrevented) return;
1380
2071
 
1381
2072
  if (bypassNextVisit) {
1382
2073
  bypassNextVisit = false;
@@ -1388,41 +2079,146 @@ export const useConfirmOnRouteLeave = ({
1388
2079
 
1389
2080
  const nextPath = normalizeUrl(visit.url.href);
1390
2081
  if (nextPath === normalizeUrl(window.location.href)) return;
1391
- if (toValue(whitelist).map(normalizeUrl).includes(nextPath)) return;
2082
+ if (
2083
+ visit.method !== 'get' &&
2084
+ toValue(whitelist).map(normalizeUrl).includes(nextPath)
2085
+ ) {
2086
+ // Marked: a save answered with a 409 redirect (a redirect with a
2087
+ // #fragment) is re-sent by Inertia with these same headers, and that
2088
+ // follow-up must not ask either.
2089
+ visit.headers = { ...visit.headers, [WHITELIST_HEADER]: '1' };
2090
+ return;
2091
+ }
1392
2092
 
1393
2093
  // `return`, not `return false`: an Inertia `before` listener that returns
1394
2094
  // false CANCELS the visit, which is the opposite of what `onBefore`
1395
2095
  // promises (false = let this visit through, no dialog).
1396
2096
  if (onBefore(visit) === false) return;
1397
2097
  event.preventDefault();
2098
+ // Held, not refused: the dialog decides. `usePromiseForm` reads this
2099
+ // flag and waits instead of answering `false` at once.
2100
+ visit.awaitingConfirm = true;
1398
2101
 
1399
2102
  void (async () => {
1400
- const confirmed = await confirm({ variant, title, text, yes, no });
1401
- if (!confirmed) return;
2103
+ const words = messages.value.leave;
2104
+ const confirmed = await confirm({
2105
+ title: title ?? words.title,
2106
+ text: text ?? words.text,
2107
+ yes: yes ?? words.yes,
2108
+ no: no ?? words.no,
2109
+ variant,
2110
+ ...(stack && { stack }),
2111
+ });
2112
+ if (!confirmed) {
2113
+ // "Keep editing" ends the held visit the way Inertia ends a
2114
+ // cancelled one, so its own callbacks (a form's processing state,
2115
+ // a pending promise) settle.
2116
+ const callbacks = visit as {
2117
+ onCancel?: () => void;
2118
+ onFinish?: (visit: Visit) => void;
2119
+ };
2120
+ callbacks.onCancel?.();
2121
+ callbacks.onFinish?.({ ...visit, cancelled: true });
2122
+ return;
2123
+ }
1402
2124
 
1403
2125
  await onLeave(visit);
2126
+ // Re-sent as a plain visit: never as a prefetch or a background one,
2127
+ // and without the visit's own onBefore (its `confirm('Sure?')` was
2128
+ // already answered).
2129
+ const {
2130
+ prefetch: _prefetch,
2131
+ async: _async,
2132
+ onBefore: _onBefore,
2133
+ awaitingConfirm: _awaitingConfirm,
2134
+ ...rest
2135
+ } = visit;
2136
+ // `before` fires inside router.visit(): the bypass covers exactly
2137
+ // that visit, and is never left armed for the next one (a re-send
2138
+ // its own callbacks stop before `before`).
1404
2139
  bypassNextVisit = true;
1405
- // Re-sent as a plain visit: never as a prefetch or a background one.
1406
- const { prefetch: _prefetch, async: _async, ...rest } = visit;
1407
2140
  router.visit(visit.url, {
1408
2141
  ...(rest as Parameters<typeof router.visit>[1]),
1409
2142
  headers: { ...visit.headers, [WHITELIST_HEADER]: '1' },
1410
2143
  });
2144
+ bypassNextVisit = false;
1411
2145
  })();
1412
2146
  });
1413
2147
 
2148
+ // Leaving the app (closing the tab, reloading, a typed URL, a plain `<a>`)
2149
+ // never reaches `before`: the browser's own prompt covers it. Not for a
2150
+ // visit this guard let through: a save answered with `Inertia::location()`
2151
+ // is a real unload with the form still dirty. Inertia sets
2152
+ // `window.location` inside that visit, and WebKit asks only after the visit
2153
+ // finished, so a let-through visit that completed with no answer (no page,
2154
+ // no error bag, no error response: Inertia left) keeps it quiet until the
2155
+ // next visit starts.
2156
+ const released = new Set<unknown>();
2157
+ let answered = false;
2158
+ let leaving = false;
2159
+ const marked = (visit: { headers?: Record<string, string> }) =>
2160
+ getHeaderValue(visit.headers, WHITELIST_HEADER) === '1';
2161
+ const unbindStart = router.on('start', ({ detail: { visit } }) => {
2162
+ leaving = false;
2163
+ answered = false;
2164
+ if (marked(visit)) released.add(visit);
2165
+ });
2166
+ // Every way a visit gets an answer; the last two pairs are Inertia 3's
2167
+ // names and Inertia 2's (each fires only on its own version).
2168
+ const onRouterEvent = (name: string, listener: () => void) =>
2169
+ router.on(name as 'start', listener);
2170
+ const unbindAnswers = [
2171
+ 'success',
2172
+ 'error',
2173
+ 'httpException',
2174
+ 'invalid',
2175
+ 'networkError',
2176
+ 'exception',
2177
+ ].map((name) => onRouterEvent(name, () => void (answered = true)));
2178
+ const unbindFinish = router.on('finish', ({ detail: { visit } }) => {
2179
+ released.delete(visit);
2180
+ if (marked(visit) && visit.completed && !answered) leaving = true;
2181
+ });
2182
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
2183
+ if (toValue(disabled) || released.size || leaving) return;
2184
+ event.preventDefault();
2185
+ event.returnValue = '';
2186
+ };
2187
+ const inBrowser = typeof window !== 'undefined';
2188
+ if (inBrowser) window.addEventListener('beforeunload', onBeforeUnload);
2189
+
1414
2190
  // `router.on` is a global bus that outlives this component: unbind it.
1415
2191
  // `onScopeDispose` over `onUnmounted`: it fires on unmount too (`setup()`
1416
2192
  // runs inside a component's own effect scope), but also works if this
1417
2193
  // composable is ever called from outside one.
1418
- onScopeDispose(unbind);
2194
+ onScopeDispose(() => {
2195
+ unbind();
2196
+ unbindStart();
2197
+ unbindAnswers.forEach((unbindAnswer) => unbindAnswer());
2198
+ unbindFinish();
2199
+ if (inBrowser) window.removeEventListener('beforeunload', onBeforeUnload);
2200
+ });
1419
2201
  };
1420
2202
  ```
1421
2203
 
1422
2204
  A prefetch never asks: `<Link prefetch>` and `<BbButton href prefetch>` fire
1423
2205
  Inertia's `before` event on hover too, and the guard lets them through (they
1424
2206
  leave nothing). The visit "Discard changes" re-sends is a plain one, never a
1425
- prefetch or an `async` visit.
2207
+ prefetch or an `async` visit, and it does not ask the visit's own `onBefore`
2208
+ (an `onBefore: () => confirm('Sure?')`) a second time. "Keep editing" ends
2209
+ the held visit as Inertia ends a cancelled one (`onCancel`, then `onFinish`
2210
+ with `cancelled: true`), so a `usePromiseForm` save held by the dialog
2211
+ answers the user's choice: the re-sent save's result, or `false`. Two guards
2212
+ on one page ask once. **Back / Forward are not guarded, by design:** Inertia restores
2213
+ those pages from history without a `before` event, and catching them would
2214
+ mean fighting Inertia's own history and scroll restore.
2215
+
2216
+ **The dialog speaks the library's `locale`.** Its default words come from
2217
+ `messages.ts` (see [Shared types and utilities](#shared-types-and-utilities)),
2218
+ which ships English only. Add your app's locale there once, keyed as you set
2219
+ the library's `locale`, and every guard follows `setConfig({ locale })`; a
2220
+ locale with no entry falls back to English. `title` / `text` / `yes` / `no`
2221
+ passed to one guard still win over it.
1426
2222
 
1427
2223
  > **`router.on(...)` subscribes to a global, module-level event bus — it
1428
2224
  > outlives the component that registered it unless you unbind it.** Inertia
@@ -1610,13 +2406,22 @@ export function useAsyncFn<T>(
1610
2406
  "Given an id, keep its resource loaded": re-fetches whenever the id
1611
2407
  changes, resets to `null` when the id clears, exposes `loading` and
1612
2408
  `refresh()`. The natural engine for a detail sidebar or peek panel — feed
1613
- it the highlighted row's id.
2409
+ it the highlighted row's id. `fn` gets an `AbortSignal` as its second argument
2410
+ (`(id, signal) => fetch(url, { signal })`): it aborts when the load goes stale
2411
+ (a newer id, a clear, the page unmounting).
1614
2412
 
1615
2413
  ```ts
1616
2414
  // inertia-helpers/useResourceContext.ts
1617
2415
  import { useLoading } from './useLoading';
1618
2416
  import { noop } from './utils';
1619
- import { ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue';
2417
+ import {
2418
+ onScopeDispose,
2419
+ ref,
2420
+ toValue,
2421
+ watch,
2422
+ type MaybeRefOrGetter,
2423
+ type Ref,
2424
+ } from 'vue';
1620
2425
 
1621
2426
  type UseResourceContextOptions<T, ID> = {
1622
2427
  immediate?: boolean;
@@ -1629,7 +2434,8 @@ type UseResourceContextOptions<T, ID> = {
1629
2434
 
1630
2435
  export function useResourceContext<T, ID = string>(
1631
2436
  id: MaybeRefOrGetter<ID | null | undefined>,
1632
- fn: (id: ID) => Promise<T>,
2437
+ /** `signal` aborts when the load goes stale: pass it to `fetch` / axios. */
2438
+ fn: (id: ID, signal: AbortSignal) => Promise<T>,
1633
2439
  options: UseResourceContextOptions<T, ID> = {}
1634
2440
  ) {
1635
2441
  const data = ref<T | null>(null) as Ref<T | null>;
@@ -1643,31 +2449,69 @@ export function useResourceContext<T, ID = string>(
1643
2449
  onFinish = noop,
1644
2450
  } = options;
1645
2451
 
1646
- const { loading, fn: fetch } = useLoading((resourceId: ID) => fn(resourceId));
2452
+ const { loading, fn: fetch } = useLoading(
2453
+ (resourceId: ID, signal: AbortSignal) => fn(resourceId, signal)
2454
+ );
2455
+
2456
+ // Each load takes a ticket; a newer load, an id change or the scope's end
2457
+ // makes older tickets stale, and a stale response is dropped (no data, no
2458
+ // callbacks): a slow answer for the row the user left never lands. Going
2459
+ // stale also aborts its request.
2460
+ let ticket = 0;
2461
+ let controller: AbortController | null = null;
2462
+ const stale = () => {
2463
+ ticket++;
2464
+ controller?.abort();
2465
+ };
2466
+ let loadedId: ID | null = null;
2467
+ const clear = () => {
2468
+ stale();
2469
+ data.value = null;
2470
+ loadedId = null;
2471
+ };
2472
+ onScopeDispose(stale);
1647
2473
 
1648
2474
  const load = async (resourceId: ID) => {
2475
+ stale();
2476
+ const own = ticket;
2477
+ controller = new AbortController();
2478
+ const outcome = await fetch(resourceId, controller.signal).then(
2479
+ (result) => ({ ok: true as const, result }),
2480
+ (error: unknown) => ({ ok: false as const, error })
2481
+ );
2482
+ if (own !== ticket) return;
1649
2483
  try {
1650
- const result = await fetch(resourceId);
1651
- data.value = result;
1652
- await onSuccess(result, resourceId);
1653
- } catch (error) {
1654
- data.value = null;
1655
- await onError(error, resourceId);
2484
+ if (outcome.ok) {
2485
+ data.value = outcome.result;
2486
+ loadedId = resourceId;
2487
+ // Outside the fetch's catch: a throwing onSuccess is the
2488
+ // caller's bug, not a failed fetch.
2489
+ await onSuccess(outcome.result, resourceId);
2490
+ } else {
2491
+ clear();
2492
+ await onError(outcome.error, resourceId);
2493
+ }
1656
2494
  } finally {
1657
2495
  await onFinish(resourceId);
1658
2496
  }
1659
2497
  };
1660
2498
 
2499
+ // Per-item sources: a re-run that yields the same id and flag (a partial
2500
+ // reload handing a new row object with the same id) does not refetch.
1661
2501
  watch(
1662
- () => ({ resourceId: toValue(id), isDisabled: toValue(disabled) }),
1663
- async ({ resourceId, isDisabled }) => {
2502
+ [() => toValue(id), () => toValue(disabled)],
2503
+ async ([resourceId, isDisabled]) => {
1664
2504
  if (!resourceId) {
1665
- data.value = null;
2505
+ clear();
1666
2506
  await options.onReset?.();
1667
2507
  return;
1668
2508
  }
1669
- // Keep already-loaded data while disabled.
1670
- if (isDisabled) return;
2509
+ // Keep already-loaded data while disabled, but only for the id it
2510
+ // was loaded for: never show row a's resource under row b.
2511
+ if (isDisabled) {
2512
+ if (resourceId !== loadedId) clear();
2513
+ return;
2514
+ }
1671
2515
  await load(resourceId as ID);
1672
2516
  },
1673
2517
  { immediate }
@@ -1697,6 +2541,10 @@ Gotchas:
1697
2541
  never rejects on a non-2xx response — a 403/404 body still parses as JSON
1698
2542
  (or throws inside `.json()`, which is _also_ swallowed by `useResourceContext`
1699
2543
  the same way). Throw when `!res.ok` so `onError` actually fires.
2544
+ - **Only the current id's answer lands.** A slower response for a row the
2545
+ user already left, or one landing after the id cleared or the scope ended,
2546
+ is dropped: no `data`, no callbacks. While `disabled`, `data` is kept only
2547
+ for the id it was loaded for; an id change clears it (re-enabling loads).
1700
2548
 
1701
2549
  ## UI utilities
1702
2550
 
@@ -1741,28 +2589,32 @@ export function useDeleteCmd(
1741
2589
 
1742
2590
  ### useScreenSizes
1743
2591
 
1744
- Shared reactive breakpoints aligned with Tailwind `md`/`lg` — one window
1745
- listener for the whole app via `createSharedComposable`.
2592
+ Shared reactive breakpoints — one window listener for the whole app via
2593
+ `createSharedComposable`. `isMobile` is the library's own signal (below the
2594
+ plugin's `mobileMaxWidth`, 768 by default), so a page and its components
2595
+ agree; tablet ends at Tailwind `lg`.
1746
2596
 
1747
2597
  ```ts
1748
2598
  // inertia-helpers/useScreenSizes.ts
1749
2599
  import { createSharedComposable, useWindowSize } from '@vueuse/core';
2600
+ import { useMobile } from 'bitboss-ui';
1750
2601
  import { computed } from 'vue';
1751
2602
 
1752
- /** Tailwind md breakpoint (px). Mobile when width < 768. */
1753
- export const SCREEN_BREAKPOINT_MD = 768;
1754
- /** Tailwind lg breakpoint (px). Tablet when 768 <= width < 1024. */
2603
+ /** Tailwind lg breakpoint (px). Tablet from the library's mobile cut up to here. */
1755
2604
  export const SCREEN_BREAKPOINT_LG = 1024;
1756
2605
 
1757
2606
  const useScreenSizesImpl = () => {
1758
2607
  const { width } = useWindowSize();
2608
+ // The library's own signal (below the plugin's `mobileMaxWidth`), so a page
2609
+ // and the components on it never disagree about "mobile".
2610
+ const { isMobile } = useMobile();
1759
2611
 
1760
- const isMobile = computed(() => width.value < SCREEN_BREAKPOINT_MD);
1761
2612
  const isTablet = computed(
1762
- () =>
1763
- width.value >= SCREEN_BREAKPOINT_MD && width.value < SCREEN_BREAKPOINT_LG
2613
+ () => !isMobile.value && width.value < SCREEN_BREAKPOINT_LG
2614
+ );
2615
+ const isDesktop = computed(
2616
+ () => !isMobile.value && width.value >= SCREEN_BREAKPOINT_LG
1764
2617
  );
1765
- const isDesktop = computed(() => width.value >= SCREEN_BREAKPOINT_LG);
1766
2618
 
1767
2619
  return { width, isMobile, isTablet, isDesktop };
1768
2620
  };