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
@@ -54562,13 +54562,14 @@ plugin (`bitbossUi({ ... })`) and cannot be changed at runtime.
54562
54562
 
54563
54563
  Use it for exactly one job: switching the locale of locale-aware components
54564
54564
  (built-in labels, close buttons, table/pagination strings) while the app runs.
54565
+ `locale` reads the current setting back, read-only.
54565
54566
 
54566
54567
  ## Import
54567
54568
 
54568
54569
  ```ts
54569
54570
  import { useBbConfig } from 'bitboss-ui';
54570
54571
 
54571
- const { setConfig } = useBbConfig();
54572
+ const { setConfig, locale } = useBbConfig();
54572
54573
  ```
54573
54574
 
54574
54575
  ## API
@@ -54583,6 +54584,12 @@ declare function setConfig(patch: Partial<DynamicConfig>): void;
54583
54584
  Merges the patch into **this app's** config. The change is immediate and
54584
54585
  reactive — every mounted component in that app which reads the key updates.
54585
54586
 
54587
+ ### `locale`
54588
+
54589
+ A read-only `ComputedRef` of this app's locale exactly as it was set (`'it'`,
54590
+ `'it-IT'`), following every `setConfig`. Read it to pick your own words in the
54591
+ same language as the library's (the Inertia helpers' `messages.ts` does).
54592
+
54586
54593
  ### Per-request locale under SSR
54587
54594
 
54588
54595
  The config is held **per Vue app**, and SSR gives every request its own app,
@@ -61094,6 +61101,12 @@ nothing imports them. Your own `@/utils` and `@/types` stay yours. If the app
61094
61101
  auto-imports `composables/**`, exclude one of the two trees until the old
61095
61102
  copies are gone, or both export `useQuery`.
61096
61103
 
61104
+ The helpers run and type-check on Inertia 2 and 3 (`@inertiajs/vue3` 2.x or
61105
+ 3.x). On Inertia 2, `useLazyProp`'s reload cannot keep the page's validation
61106
+ errors (Inertia 2 has no `preserveErrors`), and `usePromiseForm` hears a failed
61107
+ save through Inertia's global `invalid` / `exception` events, so an async reload
61108
+ that fails while a save is out counts as that save failing.
61109
+
61097
61110
  The helpers need these npm packages beyond the app's own
61098
61111
  `@inertiajs/vue3`/`vue` (`add` prints the ones a helper needs). Install them
61099
61112
  explicitly rather than relying on them being hoisted through `bitboss-ui`'s own
@@ -61109,10 +61122,12 @@ npm i -D @inertiajs/core
61109
61122
  `usePageShellContext`) and `useDeleteCmd`/`useScreenSizes`; `object-code`
61110
61123
  powers the `isEqual` hash-comparison utility below; `query-string` backs
61111
61124
  `useQuery`/`useRouteQuery`/`useQueryObject`; `@inertiajs/core` is a type-only
61112
- import (`PageProps` in `usePaginatedResponseContext`) that `@inertiajs/vue3`
61113
- does not re-export, so a strict pnpm app fails to type-check without it. The
61114
- helpers' shared types and tiny utilities are two files of the folder,
61115
- `types.ts` and `utils.ts`, below.
61125
+ import (`PageProps`, `VisitOptions`, in the URL and context helpers) that `@inertiajs/vue3`
61126
+ does not re-export, so a strict pnpm app fails to type-check without it. Install
61127
+ the version your `@inertiajs/vue3` runs on (`add` prints it pinned, e.g.
61128
+ `@inertiajs/core@3.1.1`): a newer core's visit types do not match. The
61129
+ helpers' shared types, tiny utilities and words are three files of the
61130
+ folder, `types.ts`, `utils.ts` and `messages.ts`, below.
61116
61131
 
61117
61132
  ## Shared types and utilities
61118
61133
 
@@ -61176,10 +61191,76 @@ export const isPlainObject = (
61176
61191
  typeof value === 'object' &&
61177
61192
  value !== null &&
61178
61193
  Object.getPrototypeOf(value) === Object.prototype;
61194
+
61195
+ /**
61196
+ * Deep copy of server data (arrays and plain objects; anything else, e.g. a
61197
+ * Date an app put there, is shared). Reads through Vue proxies, which
61198
+ * `structuredClone` refuses.
61199
+ */
61200
+ export const cloneDeep = <T>(value: T): T =>
61201
+ (Array.isArray(value)
61202
+ ? value.map(cloneDeep)
61203
+ : isPlainObject(value)
61204
+ ? Object.fromEntries(
61205
+ Object.entries(value).map(([key, item]) => [key, cloneDeep(item)])
61206
+ )
61207
+ : value) as T;
61208
+ ```
61209
+
61210
+ `messages.ts` holds the words a helper shows (today the leave guard's dialog),
61211
+ by the library's `locale` (`useBbConfig().locale`): `it-IT`, else `it`, else
61212
+ `en`. It is the one helper file meant to be edited: add your locales next to
61213
+ `en`. `helpers update` keeps your copy; when a release adds words here, it
61214
+ holds back this file and the helpers that read it until you merge them
61215
+ (`npx bitboss-ui helpers diff messages`).
61216
+
61217
+ ```ts
61218
+ // inertia-helpers/messages.ts
61219
+ // The words the helpers show, by the library's `locale`. Yours to edit: add
61220
+ // your app's locales next to `en` (keyed as you set `locale`: `it`, or `it-IT`
61221
+ // to tell regions apart). `helpers update` keeps your copy; if a release
61222
+ // adds words here, `npx bitboss-ui helpers diff messages` shows what to merge.
61223
+ import { useBbConfig } from 'bitboss-ui';
61224
+ import { computed } from 'vue';
61225
+
61226
+ export type HelperMessages = {
61227
+ leave: { title: string; text: string; yes: string; no: string };
61228
+ };
61229
+
61230
+ export const messages: Record<string, HelperMessages> = {
61231
+ en: {
61232
+ leave: {
61233
+ title: 'Unsaved changes',
61234
+ text: 'You have unsaved changes. Leave without saving?',
61235
+ yes: 'Discard changes',
61236
+ no: 'Keep editing',
61237
+ },
61238
+ },
61239
+ };
61240
+
61241
+ /** The messages for the current locale: `it-IT`, else `it`, else `en`. */
61242
+ export const useHelperMessages = () => {
61243
+ const { locale } = useBbConfig();
61244
+ return computed(
61245
+ () =>
61246
+ messages[locale.value] ??
61247
+ messages[locale.value.split('-')[0]!.toLowerCase()] ??
61248
+ messages.en!
61249
+ );
61250
+ };
61179
61251
  ```
61180
61252
 
61181
61253
  ---
61182
61254
 
61255
+ **Unit-testing code that uses the helpers.** A test that mocks
61256
+ `@inertiajs/vue3` has to follow Inertia's own contract, or the helpers read the
61257
+ mock as a refused or broken visit: `router.visit` / `reload` call `onStart`
61258
+ synchronously (a visit with no `onStart` counts as refused before it started);
61259
+ `onFinish` receives the visit with `cancelled` / `interrupted` / `completed`;
61260
+ `router.on` returns its unbind function. `usePaginatedResponseContext` clamps a
61261
+ page past the end in `onMounted`, so call it from a mounted component, not a
61262
+ bare `effectScope`.
61263
+
61183
61264
  ## Page props
61184
61265
 
61185
61266
  ### useProp / useOptionalProp
@@ -61272,9 +61353,17 @@ when the data lands. `loading`/`loaded`/`error` expose the fetch state;
61272
61353
  ```ts
61273
61354
  // inertia-helpers/useLazyProp.ts
61274
61355
  import { isEqual, isNil, isNotNil } from './utils';
61356
+ import type { PageProps } from '@inertiajs/core';
61275
61357
  import { router, usePage } from '@inertiajs/vue3';
61276
61358
  import type { Ref } from 'vue';
61277
- import { customRef, onBeforeUnmount, readonly, ref, watch } from 'vue';
61359
+ import {
61360
+ customRef,
61361
+ nextTick,
61362
+ onBeforeUnmount,
61363
+ readonly,
61364
+ ref,
61365
+ watch,
61366
+ } from 'vue';
61278
61367
 
61279
61368
  export type UseLazyPropOptions = {
61280
61369
  /** Keep the last non-nil value across visits that reset the prop. Default true. */
@@ -61306,7 +61395,9 @@ export function useLazyProp<T>(
61306
61395
  initialValue?: T,
61307
61396
  options: UseLazyPropOptions = {}
61308
61397
  ): UseLazyProp<T | undefined> {
61309
- const page = usePage<{ [key: string]: T | undefined }>();
61398
+ // `PageProps &` keeps usePage's constraint satisfied in an app that
61399
+ // augments Inertia's `PageProps` (TS2344 otherwise).
61400
+ const page = usePage<PageProps & { [key: string]: T | undefined }>();
61310
61401
  const { cache = true } = options;
61311
61402
  const loading = ref(false);
61312
61403
  const loaded = ref(page.props[propName] !== undefined);
@@ -61314,50 +61405,107 @@ export function useLazyProp<T>(
61314
61405
  let inFlightPromise: Promise<void> | null = null;
61315
61406
  let isUnmounted = false;
61316
61407
  let cached = page.props[propName];
61408
+ // Settles a reload a navigation cancelled, once that navigation is over.
61409
+ let resumeCancelled: (() => void) | null = null;
61317
61410
 
61318
61411
  onBeforeUnmount(() => {
61319
61412
  isUnmounted = true;
61413
+ resumeCancelled?.();
61320
61414
  });
61321
61415
 
61322
61416
  const loadProp = (onSuccess: () => void): Promise<void> => {
61323
61417
  if (inFlightPromise) return inFlightPromise;
61324
61418
 
61325
61419
  error.value = null;
61326
-
61327
- inFlightPromise = new Promise<void>((resolve) => {
61328
- router.reload({
61329
- only: [propName],
61330
- replace: true,
61331
- ...(options.headers ? { headers: options.headers } : {}),
61332
- onStart: () => {
61333
- loading.value = true;
61334
- },
61335
- onSuccess: () => {
61336
- if (!isUnmounted) {
61337
- loaded.value = true;
61338
- cached = page.props[propName];
61339
- onSuccess();
61340
- }
61341
- },
61342
- onError: (errors) => {
61343
- if (!isUnmounted) {
61344
- error.value =
61345
- errors instanceof Error
61346
- ? errors
61347
- : new Error(`Failed to load lazy prop "${propName}"`);
61348
- }
61349
- },
61350
- onFinish: () => {
61351
- if (!isUnmounted) {
61352
- loading.value = false;
61353
- }
61354
- inFlightPromise = null;
61355
- resolve();
61356
- },
61357
- });
61420
+ let started = false;
61421
+ let succeeded = false;
61422
+ let settle!: () => void;
61423
+ const promise = new Promise<void>((resolve) => {
61424
+ settle = () => {
61425
+ if (!isUnmounted) loading.value = false;
61426
+ if (inFlightPromise === promise) inFlightPromise = null;
61427
+ resolve();
61428
+ };
61358
61429
  });
61430
+ inFlightPromise = promise;
61431
+
61432
+ router.reload({
61433
+ only: [propName],
61434
+ replace: true,
61435
+ // Like Inertia's own deferred-props loader: keep the page's
61436
+ // validation errors (Inertia 3; Inertia 2 has no such option).
61437
+ ...({ preserveErrors: true } as object),
61438
+ ...(options.headers ? { headers: options.headers } : {}),
61439
+ onStart: () => {
61440
+ started = true;
61441
+ loading.value = true;
61442
+ },
61443
+ onSuccess: () => {
61444
+ succeeded = true;
61445
+ if (!isUnmounted) {
61446
+ loaded.value = true;
61447
+ cached = page.props[propName];
61448
+ onSuccess();
61449
+ }
61450
+ },
61451
+ onError: (errors) => {
61452
+ if (!isUnmounted) {
61453
+ error.value =
61454
+ errors instanceof Error
61455
+ ? errors
61456
+ : new Error(`Failed to load lazy prop "${propName}"`);
61457
+ }
61458
+ },
61459
+ onFinish: (visit) => {
61460
+ // A visit to another page cancels this reload: no failure. Stay
61461
+ // loading (a reader must not re-fire it at the outgoing URL) until
61462
+ // that navigation is over, then retry if this page is still
61463
+ // mounted (a same-component visit) or settle if it unmounted.
61464
+ if (visit.cancelled) {
61465
+ const cancelledOn = page.url;
61466
+ const unbindFinish = router.on('finish', (event) => {
61467
+ const next = event.detail.visit;
61468
+ if (next.cancelled || next.interrupted) return;
61469
+ // An async visit is over too (a mutation answered on another
61470
+ // URL redirects back with no other visit), unless it is one
61471
+ // of this page's own reloads (a poll, another lazy prop).
61472
+ if (
61473
+ next.async &&
61474
+ next.url.href === new URL(cancelledOn, next.url).href
61475
+ )
61476
+ return;
61477
+ resumeCancelled?.();
61478
+ });
61479
+ // Back / Forward swaps the page with no finish event.
61480
+ const unbindNavigate = router.on('navigate', () =>
61481
+ resumeCancelled?.()
61482
+ );
61483
+ resumeCancelled = () => {
61484
+ resumeCancelled = null;
61485
+ unbindFinish();
61486
+ unbindNavigate();
61487
+ void nextTick(() => {
61488
+ inFlightPromise = null;
61489
+ if (isUnmounted || loaded.value) settle();
61490
+ else void loadProp(onSuccess).then(settle);
61491
+ });
61492
+ };
61493
+ return;
61494
+ }
61495
+ // A network error or a non-Inertia response (Inertia's error
61496
+ // dialog) reaches only onFinish: report it. The self-loading read
61497
+ // does not retry a failed load; `execute()` does.
61498
+ if (!succeeded && !error.value && !isUnmounted) {
61499
+ error.value = new Error(`Failed to load lazy prop "${propName}"`);
61500
+ }
61501
+ settle();
61502
+ },
61503
+ });
61504
+ // Refused before it started (a `before` listener cancelled it, or no
61505
+ // `window` during SSR): no callback will ever come.
61506
+ if (!started) settle();
61359
61507
 
61360
- return inFlightPromise;
61508
+ return promise;
61361
61509
  };
61362
61510
 
61363
61511
  /** Self-loading ref: reading it while the prop is absent starts the fetch. */
@@ -61366,8 +61514,7 @@ export function useLazyProp<T>(
61366
61514
  track();
61367
61515
  const isMissing = page.props[propName] === undefined;
61368
61516
 
61369
- if (isMissing && !loaded.value && !loading.value) {
61370
- loading.value = true;
61517
+ if (isMissing && !loaded.value && !loading.value && !error.value) {
61371
61518
  void loadProp(trigger);
61372
61519
  }
61373
61520
 
@@ -61426,6 +61573,10 @@ Gotchas:
61426
61573
  load `stats.revenue`, only `stats`.
61427
61574
  - Filter navigations reset lazy props server-side; with `cache: true`
61428
61575
  (default) the last value keeps rendering while the fresh one loads.
61576
+ - A failed load (offline, a non-Inertia 500) sets `error` and is **not**
61577
+ retried by the next read — that would be a request storm. `execute()`
61578
+ retries. A load a navigation cancels is no failure: it stays `loading`
61579
+ until that navigation is over, then retries if the page is still mounted.
61429
61580
 
61430
61581
  ### useLazyPropGetter
61431
61582
 
@@ -61476,7 +61627,9 @@ export function useLazyPropGetter<T, U = T>(
61476
61627
  if (!loaded.value) {
61477
61628
  await execute();
61478
61629
  }
61479
- if (isNil(value.value)) {
61630
+ // `loaded` first: reading `value` while unloaded would start a
61631
+ // second, background reload.
61632
+ if (!loaded.value || isNil(value.value)) {
61480
61633
  throw new Error(`Could not load lazy prop "${propName}"`);
61481
61634
  }
61482
61635
  if (isNil(formatter)) return value.value as unknown as U;
@@ -61514,29 +61667,163 @@ navigation.
61514
61667
 
61515
61668
  ### useQuery
61516
61669
 
61517
- Parses the current page URL's query string into an object. Snapshot, not
61518
- reactive — used by the writers below.
61670
+ Parses the current page URL's query string into an object, the way the
61671
+ server reads it: decoded once, comma arrays split (an encoded `%2C` too, as
61672
+ PHP decodes it before Spatie splits — so with the comma format a comma inside
61673
+ a free-text value reads back as a separator, exactly as the server sees it).
61674
+ Snapshot, not reactive — used by the writers below.
61675
+
61676
+ The file is also the one URL writer every helper shares (`withQuery`,
61677
+ `visitUrl`): while a write's visit is in flight, every reader sees the URL it
61678
+ is heading to and every writer builds on it. So a sort and a direction set in
61679
+ one click both land, a filter change followed by `page = 1` keeps the filter,
61680
+ undoing a write before the server answers is not swallowed, and a ref reads
61681
+ back what was just written. Any visit that lands supersedes it. A write
61682
+ changes only its own keys: the other parameters keep their order and
61683
+ encoding, and a `#fragment` stays a fragment.
61519
61684
 
61520
61685
  ```ts
61521
61686
  // inertia-helpers/useQuery.ts
61522
61687
  // requires: npm i query-string
61523
- import { usePage } from '@inertiajs/vue3';
61688
+ import type { VisitOptions } from '@inertiajs/core';
61689
+ import { router, usePage } from '@inertiajs/vue3';
61524
61690
  import qs, { type StringifyOptions } from 'query-string';
61691
+ import { shallowRef } from 'vue';
61692
+
61693
+ /**
61694
+ * How arrays sit in the query string, for every helper that reads or writes
61695
+ * it: `tags=a,b`, what Spatie QueryBuilder parses. Change it here, once —
61696
+ * a reader and a writer that disagree turn an array into a string.
61697
+ */
61698
+ export const QUERY_ARRAY_FORMAT = 'comma';
61699
+
61700
+ /** A URL's parts. The first `?` starts the query; a `#` ends it. */
61701
+ export const splitUrl = (url: string) => {
61702
+ const hashAt = url.indexOf('#');
61703
+ const hash = hashAt < 0 ? '' : url.slice(hashAt);
61704
+ const rest = hashAt < 0 ? url : url.slice(0, hashAt);
61705
+ const queryAt = rest.indexOf('?');
61706
+ return queryAt < 0
61707
+ ? { path: rest, query: '', hash }
61708
+ : { path: rest.slice(0, queryAt), query: rest.slice(queryAt + 1), hash };
61709
+ };
61710
+
61711
+ /**
61712
+ * The URL the last write is heading to, while its visit is in flight. Every
61713
+ * reader and writer builds on it, so two writes before the server answers
61714
+ * both land, and a write reads back at once. It counts only while `page.url`
61715
+ * is still the one it was built on: any visit that lands supersedes it.
61716
+ */
61717
+ const pending = shallowRef<{ from: string; url: string } | null>(null);
61718
+
61719
+ /** `page.url`, or where the write in flight is taking it. */
61720
+ export const currentUrl = (): string => {
61721
+ const { url } = usePage();
61722
+ return pending.value?.from === url ? pending.value.url : url;
61723
+ };
61724
+
61725
+ const decodeKey = (raw: string): string => {
61726
+ try {
61727
+ return decodeURIComponent(raw.replace(/\+/g, ' '));
61728
+ } catch {
61729
+ return raw;
61730
+ }
61731
+ };
61732
+
61733
+ /**
61734
+ * `url` with `changes` written in. A nullish, empty-string or empty-array
61735
+ * value removes its key. Every other parameter is kept as it was written, in
61736
+ * its place: a write never re-encodes or re-orders what it does not own.
61737
+ */
61738
+ export const withQuery = (
61739
+ url: string,
61740
+ changes: Record<string, unknown>
61741
+ ): string => {
61742
+ const { path, query, hash } = splitUrl(url);
61743
+ const encoded = (key: string): string[] => {
61744
+ const value = changes[key];
61745
+ if (value == null || value === '') return [];
61746
+ if (Array.isArray(value) && value.length === 0) return [];
61747
+ return [
61748
+ qs.stringify({ [key]: value }, { arrayFormat: QUERY_ARRAY_FORMAT }),
61749
+ ];
61750
+ };
61751
+ const written = new Set<string>();
61752
+ const parts = query
61753
+ .split('&')
61754
+ .filter(Boolean)
61755
+ .flatMap((part) => {
61756
+ const key = decodeKey(part.split('=')[0] ?? '');
61757
+ if (!(key in changes)) return [part];
61758
+ if (written.has(key)) return [];
61759
+ written.add(key);
61760
+ return encoded(key);
61761
+ });
61762
+ Object.keys(changes)
61763
+ .filter((key) => !written.has(key))
61764
+ .forEach((key) => parts.push(...encoded(key)));
61765
+ return `${path}${parts.length ? `?${parts.join('&')}` : ''}${hash}`;
61766
+ };
61767
+
61768
+ /**
61769
+ * Takes the page to `url`: an Inertia visit, or with `server: false` a URL
61770
+ * write with no request. Readers see `url` from now until the visit finishes.
61771
+ */
61772
+ export const visitUrl = (
61773
+ url: string,
61774
+ options: Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>,
61775
+ server = true
61776
+ ): void => {
61777
+ const from = usePage().url;
61778
+ const write = { from, url };
61779
+ pending.value = write;
61780
+ const settle = () => {
61781
+ if (pending.value === write) pending.value = null;
61782
+ };
61783
+ if (server) {
61784
+ let started = false;
61785
+ router.visit(url, {
61786
+ ...options,
61787
+ onStart: (visit) => {
61788
+ started = true;
61789
+ options.onStart?.(visit);
61790
+ },
61791
+ onFinish: (visit) => {
61792
+ settle();
61793
+ options.onFinish?.(visit);
61794
+ },
61795
+ });
61796
+ // A visit a `before` listener refused never starts, and never finishes.
61797
+ if (!started) settle();
61798
+ return;
61799
+ }
61800
+ const { preserveState, preserveScroll, replace } = options;
61801
+ const client = { url, preserveState, preserveScroll, onFinish: settle };
61802
+ if (replace) router.replace(client);
61803
+ else router.push(client);
61804
+ };
61805
+
61806
+ /** The query of `url` as an object, the way the server reads it. */
61807
+ export const parseQuery = (
61808
+ url: string,
61809
+ arrayFormat: StringifyOptions['arrayFormat'] = QUERY_ARRAY_FORMAT
61810
+ ): Record<string, unknown> => {
61811
+ const { query } = splitUrl(url);
61812
+ if (!query) return {};
61813
+ // `qs.parse` decodes once. An encoded comma separates too: Laravel's pager
61814
+ // links write `a%2Cb`, which PHP decodes to `a,b` before Spatie splits it.
61815
+ return qs.parse(
61816
+ arrayFormat === 'comma' ? query.replace(/%2C/gi, ',') : query,
61817
+ { arrayFormat }
61818
+ );
61819
+ };
61525
61820
 
61526
61821
  export const useQuery = ({
61527
- arrayFormat = 'comma',
61822
+ arrayFormat = QUERY_ARRAY_FORMAT,
61528
61823
  }: { arrayFormat?: StringifyOptions['arrayFormat'] } = {}): Record<
61529
61824
  string,
61530
61825
  unknown
61531
- > => {
61532
- const { url } = usePage();
61533
- const [, queryString] = url.split('?');
61534
- if (!queryString) return {};
61535
- return qs.parse(decodeURIComponent(queryString), {
61536
- arrayFormat,
61537
- decode: true,
61538
- });
61539
- };
61826
+ > => parseQuery(currentUrl(), arrayFormat);
61540
61827
  ```
61541
61828
 
61542
61829
  **Don't swap `query-string` for a hand-rolled `URLSearchParams` reader.** It
@@ -61553,14 +61840,19 @@ assume the parsed-array shape. Install the dependency.
61553
61840
  ### useRouteQuery
61554
61841
 
61555
61842
  A single query parameter as a **writable ref**. Reading tracks the current
61556
- URL; writing merges the parameter and performs a (replace) visit, so the
61557
- server re-renders with the new value. Writing the current value is a no-op —
61558
- safe to sync from server responses without visit loops.
61843
+ URL (a repeated key reads its last value, as PHP does); writing merges the
61844
+ parameter and performs a (replace) visit, so the server re-renders with the
61845
+ new value. Writing the current value is a no-op — safe to sync from server
61846
+ responses without visit loops. Writing `null` / `undefined` or the default
61847
+ removes the key. Options, as on `useQueryObject`: `visitOptions` (Inertia's
61848
+ `VisitOptions`, merged over `preserveState` / `preserveScroll` /
61849
+ `replace: true`) and `server: false` for a URL write with no request.
61559
61850
 
61560
61851
  ```ts
61561
61852
  // inertia-helpers/useRouteQuery.ts
61562
61853
  // requires: npm i query-string
61563
- import { router, usePage } from '@inertiajs/vue3';
61854
+ import { currentUrl, splitUrl, visitUrl, withQuery } from './useQuery';
61855
+ import type { VisitOptions } from '@inertiajs/core';
61564
61856
  import qs from 'query-string';
61565
61857
  import {
61566
61858
  computed,
@@ -61572,8 +61864,15 @@ import {
61572
61864
  type Options<T> = {
61573
61865
  /** Parse the raw string (e.g. `Number`). Defaults to identity. */
61574
61866
  transform?: (value: string) => T;
61575
- preserveScroll?: boolean;
61576
- preserveState?: boolean;
61867
+ /** `false` writes the URL with no request. Default `true`. */
61868
+ server?: boolean;
61869
+ /**
61870
+ * Merged over `{ preserveState, preserveScroll, replace: true }`. With
61871
+ * `server: false` only those three apply.
61872
+ */
61873
+ visitOptions?: MaybeRefOrGetter<
61874
+ Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>
61875
+ >;
61577
61876
  };
61578
61877
 
61579
61878
  export function useRouteQuery<T = string>(
@@ -61584,37 +61883,34 @@ export function useRouteQuery<T = string>(
61584
61883
  ): WritableComputedRef<T> {
61585
61884
  const {
61586
61885
  transform = (value) => value as unknown as T,
61587
- preserveScroll = true,
61588
- preserveState = true,
61886
+ server = true,
61887
+ visitOptions,
61589
61888
  } = options;
61590
- const page = usePage();
61591
61889
 
61592
61890
  const readRaw = (): string => {
61593
- const [, queryString = ''] = page.url.split('?');
61594
- const raw = qs.parse(queryString)[key];
61595
- const single = Array.isArray(raw) ? raw[0] : raw;
61891
+ const raw = qs.parse(splitUrl(currentUrl()).query)[key];
61892
+ // A repeated key reads its last value, as PHP does.
61893
+ const single = Array.isArray(raw) ? raw.at(-1) : raw;
61596
61894
  return single != null ? String(single) : toValue(defaultValue);
61597
61895
  };
61598
61896
 
61599
61897
  return computed<T>({
61600
61898
  get: () => transform(readRaw()),
61601
61899
  set: (next) => {
61602
- const nextRaw = String(next);
61900
+ // `null` / `undefined` mean "no value": the key leaves the URL.
61901
+ const nextRaw = next == null ? toValue(defaultValue) : String(next);
61603
61902
  if (nextRaw === readRaw()) return;
61604
-
61605
- const [path = '', queryString = ''] = page.url.split('?');
61606
- const query = qs.parse(queryString);
61607
- if (nextRaw === toValue(defaultValue)) {
61608
- delete query[key]; // keep URLs clean at the default
61609
- } else {
61610
- query[key] = nextRaw;
61611
- }
61612
- const stringified = qs.stringify(query);
61613
- router.visit(stringified ? `${path}?${stringified}` : path, {
61614
- preserveScroll,
61615
- preserveState,
61616
- replace: true,
61617
- });
61903
+ const value = nextRaw === toValue(defaultValue) ? null : nextRaw; // keep URLs clean at the default
61904
+ visitUrl(
61905
+ withQuery(currentUrl(), { [key]: value }),
61906
+ {
61907
+ preserveScroll: true,
61908
+ preserveState: true,
61909
+ replace: true,
61910
+ ...toValue(visitOptions),
61911
+ },
61912
+ server
61913
+ );
61618
61914
  },
61619
61915
  });
61620
61916
  }
@@ -61623,103 +61919,120 @@ export function useRouteQuery<T = string>(
61623
61919
  ### useQueryObject
61624
61920
 
61625
61921
  The **filter engine**: watch a filters object and mirror its non-empty
61626
- values into the query string with an Inertia visit per change. Keys it has
61627
- seen are cleaned up when they empty out; the `page` parameter resets on
61628
- every filter change so results are never hidden on an out-of-range page.
61922
+ values into the query string with one Inertia visit per change. Keys it has
61923
+ seen are cleaned up when they empty out; keys it does not own (a tab, a sort)
61924
+ are kept. Every change also removes `resetKeys` (default `['page']`), so
61925
+ results are never hidden on an out-of-range page.
61926
+
61927
+ | Option | Default | What it does |
61928
+ | -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
61929
+ | `visitOptions` | — | Inertia's own `VisitOptions` (`only`, `headers`, `reset`, callbacks…), merged over `preserveState` / `preserveScroll` / `replace: true` |
61930
+ | `debounce` | `{}` | Per-key wait in ms for typed keys: `{ search: 300 }` |
61931
+ | `resetKeys` | `['page']` | Keys removed on every change. Passing it **replaces** the default: `['page', 'lots_page']`, or `[]` to keep the page |
61932
+ | `paused` | `false` | While `true`, track changes but send nothing; resuming sends the current state once (see below) |
61933
+ | `server` | `true` | `false` writes the URL with no request (`router.replace` / `push`); `visitOptions` then takes only `preserveState` / `preserveScroll` / `replace` |
61934
+
61935
+ Arrays are written in the one `QUERY_ARRAY_FORMAT` of `useQuery.ts` (`comma`:
61936
+ `tags=a,b`, what Spatie QueryBuilder parses), the same one `useQuery` reads.
61937
+ To send server-shaped keys (`filter[status]`) or reshape values, pass a getter
61938
+ that builds them: `useQueryObject(() => ({ 'filter[status]': f.status }))`.
61629
61939
 
61630
61940
  ```ts
61631
61941
  // inertia-helpers/useQueryObject.ts
61632
61942
  // requires: npm i query-string
61633
- import { useQuery } from './useQuery';
61634
- import { isEmpty, isNil } from './utils';
61635
- import { router, usePage } from '@inertiajs/vue3';
61636
- import qs, { type StringifyOptions } from 'query-string';
61943
+ import {
61944
+ QUERY_ARRAY_FORMAT,
61945
+ currentUrl,
61946
+ parseQuery,
61947
+ visitUrl,
61948
+ withQuery,
61949
+ } from './useQuery';
61950
+ import type { VisitOptions } from '@inertiajs/core';
61951
+ import qs from 'query-string';
61637
61952
  import { onScopeDispose, toValue, watch, type MaybeRefOrGetter } from 'vue';
61638
61953
 
61954
+ /** A server visit's options; the URL, method and data are the helper's. */
61955
+ type ServerVisitOptions = Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>;
61956
+ /** A URL-only write (`server: false`) has no request to configure. */
61957
+ type ClientVisitOptions = Pick<
61958
+ VisitOptions,
61959
+ 'preserveState' | 'preserveScroll' | 'replace'
61960
+ >;
61961
+
61639
61962
  type Options<T> = {
61640
- transform?: (value: Partial<T>) => Partial<T>;
61641
- /** Reset the `page` parameter on change. Default true. */
61642
- resetPage?: boolean;
61643
- /** Additional query keys removed whenever the object changes. */
61963
+ /**
61964
+ * Query keys removed on every change. Default `['page']`, so a filter
61965
+ * change never strands the list on a page past its new end. Passing it
61966
+ * REPLACES the default: name `page` too when it should still reset.
61967
+ */
61644
61968
  resetKeys?: string[];
61645
- /** Inertia partial reload prop names (supports reactive getter). */
61646
- only?: MaybeRefOrGetter<string[]>;
61647
61969
  /**
61648
- * When it resolves to `false` the watcher keeps tracking keys but skips
61649
- * the navigation — use it when another owner performs the visit during a
61650
- * programmatic transition, to avoid competing self-cancelling requests.
61970
+ * While it resolves to `true`, changes are tracked but no visit is sent.
61971
+ * When it goes back to `false`, the object's current state is sent once,
61972
+ * if the URL does not already hold it. See the guide for when to use it.
61651
61973
  */
61652
- shouldNavigate?: MaybeRefOrGetter<boolean>;
61974
+ paused?: MaybeRefOrGetter<boolean>;
61653
61975
  /**
61654
61976
  * Keys whose changes wait this many ms before the visit, e.g. a search
61655
61977
  * box: `{ search: 300 }`. A change to any other key visits at once and
61656
61978
  * carries the waiting value with it, so nothing typed is lost.
61657
61979
  */
61658
61980
  debounce?: Partial<Record<keyof T & string, number>>;
61659
- arrayFormat?: StringifyOptions['arrayFormat'];
61660
- preserveState?: boolean;
61661
- preserveScroll?: boolean;
61662
- replace?: boolean;
61663
- };
61981
+ } & (
61982
+ | {
61983
+ /** Default `true`: every change is an Inertia visit. */
61984
+ server?: true;
61985
+ /** Merged over `{ preserveState, preserveScroll, replace: true }`. */
61986
+ visitOptions?: MaybeRefOrGetter<ServerVisitOptions>;
61987
+ }
61988
+ | {
61989
+ /** `false` writes the URL with no request (`router.replace` / `push`). */
61990
+ server: false;
61991
+ visitOptions?: MaybeRefOrGetter<ClientVisitOptions>;
61992
+ }
61993
+ );
61664
61994
 
61665
61995
  export function useQueryObject<T extends Record<string, unknown>>(
61666
61996
  object: MaybeRefOrGetter<T>,
61667
61997
  options: Options<T> = {}
61668
61998
  ): void {
61669
61999
  const {
61670
- transform,
61671
- resetPage = true,
61672
- resetKeys = [],
61673
- preserveState = true,
61674
- preserveScroll = true,
61675
- replace = true,
61676
- only: onlyOption = [],
61677
- shouldNavigate = true,
62000
+ resetKeys = ['page'],
62001
+ paused = false,
61678
62002
  debounce = {},
61679
- arrayFormat = 'comma',
62003
+ server = true,
62004
+ visitOptions,
61680
62005
  } = options;
61681
62006
 
61682
- const filterValidValues = (obj: T): Partial<T> => {
61683
- const filtered: Partial<T> = {};
61684
- Object.entries(obj).forEach(([key, val]) => {
61685
- if (!isNil(val) && val !== '') {
61686
- (filtered as Record<string, unknown>)[key] = val;
61687
- }
61688
- });
61689
- return filtered;
61690
- };
61691
-
61692
62007
  /** Every key this composable has ever managed, for stable URL cleanup. */
61693
62008
  const seenKeys = new Set<string>(Object.keys(toValue(object) ?? {}));
61694
62009
 
61695
- const navigate = (newObject: T) => {
61696
- const validValues = filterValidValues(newObject);
61697
- const transformedValues = transform ? transform(validValues) : validValues;
61698
-
61699
- // Start from the existing query to preserve keys owned elsewhere.
61700
- const existingQuery = useQuery({ arrayFormat });
61701
- seenKeys.forEach((key) => delete existingQuery[key]);
61702
- if (resetPage) delete existingQuery.page;
61703
- resetKeys.forEach((key) => delete existingQuery[key]);
61704
-
61705
- Object.entries(transformedValues).forEach(([key, val]) => {
61706
- if (!isEmpty(val)) existingQuery[key] = val;
61707
- });
62010
+ /** A query in one canonical form, so encoding and key order never count. */
62011
+ const canonical = (url: string) =>
62012
+ qs.stringify(parseQuery(url), { arrayFormat: QUERY_ARRAY_FORMAT });
61708
62013
 
61709
- const stringified = qs.stringify(existingQuery, { arrayFormat });
61710
- const newUrl = stringified
61711
- ? [window.location.pathname, stringified].join('?')
61712
- : window.location.pathname;
61713
-
61714
- if (newUrl === usePage().url) return;
61715
- if (!toValue(shouldNavigate)) return;
61716
-
61717
- router.visit(newUrl, {
61718
- preserveState,
61719
- preserveScroll,
61720
- only: toValue(onlyOption),
61721
- replace,
61722
- });
62014
+ const navigate = (newObject: T) => {
62015
+ if (toValue(paused)) return;
62016
+
62017
+ // Only the reset keys and the keys this object owns change; every
62018
+ // other parameter (a tab, a sort) stays as it is.
62019
+ const changes: Record<string, unknown> = {};
62020
+ resetKeys.forEach((key) => (changes[key] = null));
62021
+ seenKeys.forEach((key) => (changes[key] = newObject[key]));
62022
+ const from = currentUrl();
62023
+ const url = withQuery(from, changes);
62024
+ if (canonical(url) === canonical(from)) return;
62025
+
62026
+ visitUrl(
62027
+ url,
62028
+ {
62029
+ preserveState: true,
62030
+ preserveScroll: true,
62031
+ replace: true,
62032
+ ...toValue(visitOptions),
62033
+ },
62034
+ server
62035
+ );
61723
62036
  };
61724
62037
 
61725
62038
  /** Each key's value as last seen, to tell which keys a change touched. */
@@ -61732,8 +62045,8 @@ export function useQueryObject<T extends Record<string, unknown>>(
61732
62045
  onScopeDispose(() => clearTimeout(pending));
61733
62046
 
61734
62047
  watch(
61735
- () => toValue(object),
61736
- (newObject) => {
62048
+ [() => toValue(object), () => toValue(paused)],
62049
+ ([newObject, isPaused], [, wasPaused]) => {
61737
62050
  if (!newObject) return;
61738
62051
 
61739
62052
  Object.keys(newObject).forEach((key) => {
@@ -61752,12 +62065,15 @@ export function useQueryObject<T extends Record<string, unknown>>(
61752
62065
  );
61753
62066
  lastSeen = now;
61754
62067
  clearTimeout(pending);
62068
+ if (isPaused) return;
62069
+ // Resuming sends what changed while paused, without waiting.
62070
+ const resumed = wasPaused && !isPaused;
61755
62071
  const waits = changed.map(
61756
62072
  (key) => (debounce as Record<string, number | undefined>)[key] ?? 0
61757
62073
  );
61758
62074
  // Only debounced keys changed: wait for the typing to settle, then
61759
62075
  // visit with whatever the object holds by then.
61760
- if (changed.length && waits.every((ms) => ms > 0)) {
62076
+ if (!resumed && changed.length && waits.every((ms) => ms > 0)) {
61761
62077
  pending = setTimeout(
61762
62078
  () => navigate(toValue(object)),
61763
62079
  Math.max(...waits)
@@ -61771,7 +62087,8 @@ export function useQueryObject<T extends Record<string, unknown>>(
61771
62087
  * into one watcher run, so a `clearFilters()` that blanks three keys
61772
62088
  * fires ONE visit instead of three (measured: 3 writes → 3 runs under
61773
62089
  * `sync`, 1 under the default). The two it saves used to self-cancel,
61774
- * but they were still sent.
62090
+ * but they were still sent. Watching `paused` in the same source means
62091
+ * resuming and the change that comes with it are one run, one visit.
61775
62092
  *
61776
62093
  * This does NOT make typing cheap on its own — one visit per settled
61777
62094
  * keystroke is still one visit per keystroke. `debounce` is for that.
@@ -61784,7 +62101,10 @@ export function useQueryObject<T extends Record<string, unknown>>(
61784
62101
  ```ts
61785
62102
  // A listing page's whole filter wiring:
61786
62103
  const filters = reactive({ search: '', status: null as string | null });
61787
- useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
62104
+ useQueryObject(filters, {
62105
+ visitOptions: { only: ['invoices'] },
62106
+ debounce: { search: 300 },
62107
+ });
61788
62108
  // Bind BbTextInput / BbSelectPopover to `filters.*` — every change becomes a
61789
62109
  // partial reload and the server re-renders the `invoices` prop.
61790
62110
  ```
@@ -61793,9 +62113,9 @@ useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
61793
62113
  > their own URL writers (the `page` / `perPage` refs of
61794
62114
  > `usePaginatedResponseContext`, or `useRouteQuery`). Put the page ref in the
61795
62115
  > object too and a pager click visits twice: once through the ref, once
61796
- > through this watcher. And `resetPage` stops working: `navigate()` deletes
61797
- > `page` from the query, then writes the object's value back, so a filter
61798
- > change keeps the old page, possibly past the last one.
62116
+ > through this watcher. And the page reset stops working: `navigate()`
62117
+ > deletes `page` from the query, then writes the object's value back, so a
62118
+ > filter change keeps the old page, possibly past the last one.
61799
62119
 
61800
62120
  > **Typing: debounce the key, not the page.** Without `debounce`, "an
61801
62121
  > Inertia visit per change" is literal: a text field bound straight to
@@ -61805,16 +62125,63 @@ useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
61805
62125
  >
61806
62126
  > ```ts
61807
62127
  > const filters = reactive({ search: '', status: null as string | null });
61808
- > useQueryObject(filters, { only: ['invoices'], debounce: { search: 300 } });
62128
+ > useQueryObject(filters, {
62129
+ > visitOptions: { only: ['invoices'] },
62130
+ > debounce: { search: 300 },
62131
+ > });
61809
62132
  > ```
61810
62133
  >
61811
62134
  > Typing in the search waits 300 ms of quiet; picking a status visits at once
61812
62135
  > and takes the typed text with it. Keys not named in `debounce` never wait.
61813
- >
61814
- > **Programmatic multi-key writes** (reset, apply-a-saved-view): gate them
61815
- > with `shouldNavigate` and perform the one visit yourself once the object is
61816
- > settled. That is what the option is for: the watcher keeps tracking keys
61817
- > for URL cleanup while the navigation is yours.
62136
+
62137
+ > **Programmatic multi-key writes need nothing.** A reset or an
62138
+ > apply-a-saved-view that writes several keys in one tick is already one
62139
+ > visit: the watcher runs once per tick. Do not reach for `paused` for this.
62140
+
62141
+ #### `paused`: when another visit owns the page for a while
62142
+
62143
+ Use it when something else sends a visit that also changes keys this object
62144
+ mirrors, and that visit takes time: a view switch whose own `router.get`
62145
+ rewrites the filters, a wizard step that navigates and seeds the next step's
62146
+ filters. Without it, the object's watcher fires on the next tick with the
62147
+ seeded values and sends a second visit; Inertia cancels the first (the one
62148
+ that mattered) in favour of it.
62149
+
62150
+ ```ts
62151
+ const switching = ref(false);
62152
+ useQueryObject(filters, {
62153
+ visitOptions: { only: ['conversations'] },
62154
+ paused: switching,
62155
+ });
62156
+
62157
+ const switchView = (view: string) => {
62158
+ switching.value = true;
62159
+ Object.assign(filters, filtersFor(view)); // tracked, not sent
62160
+ router.get(
62161
+ `/inbox/${view}`,
62162
+ {},
62163
+ {
62164
+ onFinish: () => (switching.value = false), // also runs on cancel
62165
+ }
62166
+ );
62167
+ };
62168
+ ```
62169
+
62170
+ What happens, exactly:
62171
+
62172
+ - **While `paused` is `true`**, every change is tracked (key cleanup, the
62173
+ debounce bookkeeping) and nothing is sent. A debounced change already
62174
+ waiting when the pause starts is dropped, not sent late.
62175
+ - **When it goes back to `false`**, the object's _current_ state is sent once,
62176
+ without waiting for a debounce. If the URL already holds that state — the
62177
+ other visit landed exactly these filters, the usual case — nothing is sent.
62178
+ Resuming and a change in the same tick are one visit.
62179
+ - It is a **span over time**, not a tick guard: set and cleared in the same
62180
+ tick, it pauses nothing (the watcher runs after the tick, when it is already
62181
+ `false`). Clear it in the competing visit's `onFinish`, which Inertia also
62182
+ calls when that visit is cancelled or fails, so the object never stays
62183
+ paused.
62184
+ - It accepts a ref or a getter: `paused: () => form.processing`.
61818
62185
 
61819
62186
  ---
61820
62187
 
@@ -61825,31 +62192,50 @@ counterpart of the component-state context
61825
62192
  [`useBbTableContext`](https://llms-bitboss-ui.bitboss.it/dist/ai/composables/useBbTableContext.md). Rule of thumb:
61826
62193
  `useBbTableContext` mirrors what the _table widget_ is doing (selection,
61827
62194
  sort, highlight); these contexts own what the _server sent_ (items, meta)
61828
- and survive being read from any component on the page. First caller creates
61829
- the context, later callers join it — no provider component required.
62195
+ and survive being read from any component on the page. The first caller
62196
+ creates the context and every later caller **below it** joins it — no
62197
+ provider component, but siblings (a table and a bulk bar) share one only
62198
+ when a common parent (the page) calls it first; each would otherwise create
62199
+ its own.
61830
62200
 
61831
62201
  ### usePaginatedResponseContext
61832
62202
 
61833
62203
  Wraps a Laravel paginated prop (`PaginatedResponse<T>`) into a shared
61834
- context: `items` (editable until the prop changes), `totalItems`,
61835
- `totalPages`, and `page` / `perPage` refs that read what the server answered
61836
- and visit when written (`page` at 1 and `per_page` at the server's default
61837
- leave the URL).
62204
+ context: `items` (a deep copy of the rows: `v-model` a row's field for inline
62205
+ editing without touching the page prop; editable until the prop changes),
62206
+ `totalItems`, `totalPages`, and `page` / `perPage` refs that read what the
62207
+ server answered and visit when written (`page` at 1 and `per_page` at the
62208
+ server's default leave the URL; a new `perPage` starts again from page 1). A
62209
+ missing or deferred prop reads as an empty page.
62210
+
62211
+ **When `items` resets.** A server visit that asks for this prop (a pager
62212
+ click, a filter, `router.reload({ only: ['users'] })`) always brings fresh
62213
+ rows, even identical ones: reloading after a failed delete undoes the
62214
+ optimistic edit. Anything else that swaps the prop — `router.appendToProp`
62215
+ on another prop rebuilds every prop, a reload of another prop, Back /
62216
+ Forward — keeps your edits unless the rows really changed. Anything past
62217
+ that (rolling back a failed edit, merging a concurrent change) is the app's.
61838
62218
 
61839
62219
  ```ts
61840
62220
  // inertia-helpers/usePaginatedResponseContext.ts
61841
- import { useRouteQuery } from './useRouteQuery';
62221
+ // requires: npm i query-string
62222
+ import { currentUrl, visitUrl, withQuery } from './useQuery';
61842
62223
  import type { PaginatedResponse } from './types';
61843
- import { isNotNil } from './utils';
61844
- import type { PageProps } from '@inertiajs/core';
61845
- import { usePage } from '@inertiajs/vue3';
62224
+ import { cloneDeep, isEqual, isNotNil } from './utils';
62225
+ import type { PageProps, VisitOptions } from '@inertiajs/core';
62226
+ import { router, usePage } from '@inertiajs/vue3';
61846
62227
  import { injectLocal, provideLocal } from '@vueuse/core';
61847
62228
  import {
61848
62229
  computed,
62230
+ onMounted,
62231
+ onScopeDispose,
61849
62232
  ref,
62233
+ shallowRef,
62234
+ toValue,
61850
62235
  watch,
61851
62236
  watchEffect,
61852
62237
  type ComputedRef,
62238
+ type MaybeRefOrGetter,
61853
62239
  type Ref,
61854
62240
  } from 'vue';
61855
62241
 
@@ -61858,10 +62244,21 @@ export type PaginatedResponseContext<T> = {
61858
62244
  perPage: Ref<number>;
61859
62245
  page: Ref<number>;
61860
62246
  totalPages: ComputedRef<number>;
61861
- /** Current page items. Writable for optimistic edits; resets on navigation. */
62247
+ /** Current page items, a deep copy: edit it freely; resets on a new answer. */
61862
62248
  items: Ref<T[]>;
61863
62249
  };
61864
62250
 
62251
+ export type PaginatedResponseOptions = {
62252
+ /** The query key the server reads the page from (Laravel's `pageName`). Default `'page'`. */
62253
+ pageQueryKey?: string;
62254
+ /** The query key the server reads the page size from. Default `'per_page'`. */
62255
+ perPageQueryKey?: string;
62256
+ /** Merged over `{ preserveState, preserveScroll }` and the history default (a page pushes, a size replaces). */
62257
+ visitOptions?: MaybeRefOrGetter<
62258
+ Omit<VisitOptions, 'method' | 'data' | 'preserveUrl'>
62259
+ >;
62260
+ };
62261
+
61865
62262
  const toContextKey = (key: string): string => `${key}:paginatedResponse`;
61866
62263
 
61867
62264
  /**
@@ -61869,30 +62266,40 @@ const toContextKey = (key: string): string => `${key}:paginatedResponse`;
61869
62266
  * fields under `meta` (a resource collection, a hand-built shape) or at the
61870
62267
  * top level (`->paginate()`, `->simplePaginate()`). `simplePaginate()` knows
61871
62268
  * no total: the count is the rows so far, and the last page is the next one
61872
- * while a next page exists.
62269
+ * while a next page exists, or the one before an empty page past the end.
61873
62270
  */
61874
62271
  export const readPagination = (paginated: PaginatedResponse<unknown>) => {
61875
62272
  const fields = paginated.meta ?? paginated;
61876
62273
  const current = fields.current_page ?? 1;
61877
62274
  const perPage = fields.per_page ?? 10;
62275
+ const rows = paginated.data?.length ?? 0;
61878
62276
  const links = paginated.links as { next?: string | null } | undefined;
61879
62277
  const hasNext = !!(
61880
62278
  fields.next_page_url ?? (Array.isArray(links) ? null : links?.next)
61881
62279
  );
62280
+ const pastEnd = !rows && !hasNext && current > 1;
61882
62281
  return {
61883
62282
  current,
61884
62283
  perPage,
61885
- lastPage: fields.last_page ?? (hasNext ? current + 1 : current),
62284
+ lastPage:
62285
+ fields.last_page ??
62286
+ (hasNext ? current + 1 : pastEnd ? current - 1 : current),
61886
62287
  total:
61887
62288
  fields.total ??
61888
- (current - 1) * perPage +
61889
- (paginated.data?.length ?? 0) +
61890
- (hasNext ? 1 : 0),
62289
+ (pastEnd ? 0 : (current - 1) * perPage + rows + (hasNext ? 1 : 0)),
61891
62290
  };
61892
62291
  };
61893
62292
 
62293
+ const isCount = (value: number): boolean =>
62294
+ Number.isInteger(value) && value > 0;
62295
+
61894
62296
  export const usePaginatedResponseContext = <T = object>(
61895
- dataKey: string
62297
+ dataKey: string,
62298
+ {
62299
+ pageQueryKey = 'page',
62300
+ perPageQueryKey = 'per_page',
62301
+ visitOptions,
62302
+ }: PaginatedResponseOptions = {}
61896
62303
  ): PaginatedResponseContext<T> => {
61897
62304
  const existing = injectLocal<PaginatedResponseContext<T> | null>(
61898
62305
  toContextKey(dataKey),
@@ -61911,18 +62318,46 @@ export const usePaginatedResponseContext = <T = object>(
61911
62318
  const inertiaPage = usePage<
61912
62319
  PageProps & Record<string, PaginatedResponse<T>>
61913
62320
  >();
62321
+ // A deferred, optional or not-yet-reloaded prop reads as an empty page.
61914
62322
  const paginated = computed(
61915
- () => inertiaPage.props[dataKey] as PaginatedResponse<T>
62323
+ (): PaginatedResponse<T> => inertiaPage.props[dataKey] ?? { data: [] }
61916
62324
  );
61917
62325
 
61918
62326
  const pagination = computed(() => readPagination(paginated.value));
61919
- const items = ref<T[]>(paginated.value.data ?? []) as Ref<T[]>;
62327
+ // A deep copy: inline edits (`v-model` on a row's field) stay off the page
62328
+ // prop, so the "rows really changed" check below compares two things.
62329
+ const items = ref<T[]>(cloneDeep(paginated.value.data ?? [])) as Ref<T[]>;
61920
62330
  const totalItems = computed(() => pagination.value.total);
61921
62331
  const totalPages = computed(() => pagination.value.lastPage);
61922
62332
 
61923
- // A new prop is a new answer (a visit, Back / Forward); a partial reload of
61924
- // another prop keeps this one, and the edits made to `items`.
61925
- watch(paginated, (fresh) => (items.value = fresh.data ?? []));
62333
+ // Server visits in flight that ask for this prop (a pager click, a filter,
62334
+ // `router.reload({ only: [dataKey] })`).
62335
+ const asking = new Set<unknown>();
62336
+ const asks = ({ only, except }: { only: string[]; except: string[] }) =>
62337
+ only.length ? only.includes(dataKey) : !except.includes(dataKey);
62338
+ const unbindStart = router.on('start', ({ detail: { visit } }) => {
62339
+ if (asks(visit)) asking.add(visit);
62340
+ });
62341
+ const unbindFinish = router.on('finish', ({ detail: { visit } }) => {
62342
+ asking.delete(visit);
62343
+ });
62344
+ onScopeDispose(() => {
62345
+ unbindStart();
62346
+ unbindFinish();
62347
+ });
62348
+
62349
+ // A visit that asked for this prop is a new answer: fresh rows, the edits
62350
+ // made to `items` go. Anything else that swaps the prop (`appendToProp` on
62351
+ // another prop rebuilds them all, Back / Forward) keeps the edits unless the
62352
+ // rows really changed. `sync`: the answer lands before its visit finishes.
62353
+ watch(
62354
+ paginated,
62355
+ (fresh, old) => {
62356
+ if (!asking.size && isEqual(fresh.data, old.data)) return;
62357
+ items.value = cloneDeep(fresh.data ?? []);
62358
+ },
62359
+ { flush: 'sync' }
62360
+ );
61926
62361
 
61927
62362
  // The server's default page size is what it answers a URL without
61928
62363
  // `per_page`. Until such an answer arrives it is unknown (`''` matches no
@@ -61931,24 +62366,74 @@ export const usePaginatedResponseContext = <T = object>(
61931
62366
  const defaultPerPage = ref('');
61932
62367
  watchEffect(() => {
61933
62368
  const [, queryString = ''] = inertiaPage.url.split('?');
61934
- if (!new URLSearchParams(queryString).has('per_page'))
62369
+ if (!new URLSearchParams(queryString).has(perPageQueryKey))
61935
62370
  defaultPerPage.value = `${pagination.value.perPage}`;
61936
62371
  });
61937
- const pageQuery = useRouteQuery('page', '1');
61938
- const perPageQuery = useRouteQuery('per_page', defaultPerPage);
62372
+
62373
+ // Where the last pager write is going, while its visit is in flight: a
62374
+ // second click before the answer builds on it ("next" twice is page 3).
62375
+ const target = shallowRef<{ url: string; page: number; perPage: number }>();
62376
+ const inFlight = () => {
62377
+ const write = target.value;
62378
+ return write?.url === currentUrl() && write.url !== inertiaPage.url
62379
+ ? write
62380
+ : undefined;
62381
+ };
62382
+ const go = (to: number, size?: number, extra?: { replace: true }) => {
62383
+ const url = withQuery(currentUrl(), {
62384
+ [pageQueryKey]: to === 1 ? null : to,
62385
+ ...(isNotNil(size) && {
62386
+ [perPageQueryKey]: `${size}` === defaultPerPage.value ? null : size,
62387
+ }),
62388
+ });
62389
+ target.value = { url, page: to, perPage: size ?? perPage.value };
62390
+ visitUrl(url, {
62391
+ preserveScroll: true,
62392
+ preserveState: true,
62393
+ // A page is a history entry (Back goes to the previous page); a new
62394
+ // size is a setting and replaces.
62395
+ replace: isNotNil(size),
62396
+ ...toValue(visitOptions),
62397
+ ...extra,
62398
+ });
62399
+ };
61939
62400
 
61940
62401
  // Both read the server's answer, not the URL: a clamped `?page=99`, a
61941
62402
  // capped `?per_page=500` or a redirect to the bare URL shows what the
61942
- // server sent. Writing one visits, and only when the URL changes.
62403
+ // server sent. Writing one visits when it changes what they show; a size
62404
+ // starts again from page 1. Junk (0, NaN, 2.5, past a known last page) is
62405
+ // ignored.
61943
62406
  const page = computed({
61944
- get: () => pagination.value.current,
61945
- set: (next: number) => (pageQuery.value = `${next}`),
62407
+ get: () => inFlight()?.page ?? pagination.value.current,
62408
+ set: (next: number) => {
62409
+ const last = (paginated.value.meta ?? paginated.value).last_page;
62410
+ if (!isCount(next) || (isNotNil(last) && next > last)) return;
62411
+ if (next !== page.value) go(next);
62412
+ },
61946
62413
  });
61947
62414
  const perPage = computed({
61948
- get: () => pagination.value.perPage,
61949
- set: (next: number) => (perPageQuery.value = `${next}`),
62415
+ get: () => inFlight()?.perPage ?? pagination.value.perPage,
62416
+ set: (next: number) => {
62417
+ if (isCount(next) && next !== perPage.value) go(1, next);
62418
+ },
61950
62419
  });
61951
62420
 
62421
+ // Laravel's `paginate()` does not clamp: `?page=3` after page 3's last row
62422
+ // went answers an empty page 3 of 2. Go to the last page, once per answer.
62423
+ let clampedFrom = '';
62424
+ onMounted(() =>
62425
+ watch(
62426
+ pagination,
62427
+ ({ current, lastPage }) => {
62428
+ if (current <= lastPage || lastPage < 1) return;
62429
+ if (clampedFrom === inertiaPage.url) return;
62430
+ clampedFrom = inertiaPage.url;
62431
+ go(lastPage, undefined, { replace: true });
62432
+ },
62433
+ { immediate: true }
62434
+ )
62435
+ );
62436
+
61952
62437
  const context: PaginatedResponseContext<T> = {
61953
62438
  totalItems,
61954
62439
  perPage,
@@ -61981,6 +62466,38 @@ return Inertia::render('Admin/Users/Index', [
61981
62466
  one while a next page exists) and `totalPages` ends at the next page, so a
61982
62467
  pager shows "next" until the last page.
61983
62468
 
62469
+ **`->cursorPaginate()` is not supported.** It has no page numbers (only
62470
+ `next_cursor` / `prev_cursor` links), so `page` would read 1 on every page
62471
+ and "next" would visit a `?page=2` Laravel ignores. Use `->paginate()` or
62472
+ `->simplePaginate()` with this context.
62473
+
62474
+ **A page is a history entry.** Writing `page` pushes, so Back goes to the
62475
+ previous page; a new `perPage` (a setting) and filter writes replace. Pass
62476
+ `visitOptions: { replace: true }` to keep the pager out of history.
62477
+
62478
+ **A page past the last goes to the last.** Laravel does not clamp: delete
62479
+ the last row of page 3 and `?page=3` answers an empty page 3 of 2. The
62480
+ context visits the last page once (`replace`); an empty `simplePaginate()`
62481
+ page past the first goes back one.
62482
+
62483
+ **Writes before the answer build on each other.** While a pager visit is in
62484
+ flight, `page` / `perPage` read where it is going, so "next" clicked twice
62485
+ lands two pages on, and a click right after a filter or size change keeps
62486
+ it. A write equal to what they show, a page that is not a positive integer,
62487
+ or a page past a known last page makes no visit.
62488
+
62489
+ Options (the second argument, also taken by `useTableContext`):
62490
+
62491
+ - `pageQueryKey` / `perPageQueryKey` — the query keys the server reads
62492
+ (defaults `'page'` / `'per_page'`). A paginator with its own page name,
62493
+ `->paginate(15, ['*'], 'lots_page')`, takes
62494
+ `usePaginatedResponseContext('lots', { pageQueryKey: 'lots_page' })` — and
62495
+ two tables on one page need one each.
62496
+ - `visitOptions` — merged over `{ preserveState, preserveScroll, replace:
62497
+ true }` on every pager visit, a ref or getter read each time. Without it a
62498
+ page change reloads every prop; `{ only: ['users'] }` reloads the table
62499
+ alone.
62500
+
61984
62501
  ### useTableSelectionContext
61985
62502
 
61986
62503
  Shared selection state by key, in server terms: `selected` and `unselected`
@@ -61989,6 +62506,12 @@ item lists plus the `all` flag ("select-all across pages" semantics — when
61989
62506
  combines `all` + `unselected` instead). Bind these to `BbTable`'s
61990
62507
  `v-model`, `v-model:unselected-items` and `v-model:select-all`.
61991
62508
 
62509
+ The selection is never reset for you: it survives page changes (that is
62510
+ the cross-page feature) and filter changes alike, since the URL also holds
62511
+ state that has nothing to do with the table. When a bulk action must not
62512
+ reach rows the new filter hides, call `resetSelection()` where the filters
62513
+ change, e.g. `watch(filters, resetSelection, { deep: true })`.
62514
+
61992
62515
  ```ts
61993
62516
  // inertia-helpers/useTableSelectionContext.ts
61994
62517
  import { isNotNil } from './utils';
@@ -62052,6 +62575,7 @@ builds on.
62052
62575
  import {
62053
62576
  usePaginatedResponseContext,
62054
62577
  type PaginatedResponseContext,
62578
+ type PaginatedResponseOptions,
62055
62579
  } from './usePaginatedResponseContext';
62056
62580
  import {
62057
62581
  useTableSelectionContext,
@@ -62066,7 +62590,9 @@ export type TableContext<T, U> = TableSelectionContext<U> &
62066
62590
  const toTableKey = (key: string): string => `${key}:table`;
62067
62591
 
62068
62592
  export const useTableContext = <T = object, U = T>(
62069
- dataKey: string
62593
+ dataKey: string,
62594
+ /** For the paginated half: query keys and pager visit options. */
62595
+ options?: PaginatedResponseOptions
62070
62596
  ): TableContext<T, U> => {
62071
62597
  const existing = injectLocal<TableContext<T, U> | null>(
62072
62598
  toTableKey(dataKey),
@@ -62076,7 +62602,7 @@ export const useTableContext = <T = object, U = T>(
62076
62602
 
62077
62603
  const context: TableContext<T, U> = {
62078
62604
  ...useTableSelectionContext<U>(dataKey),
62079
- ...usePaginatedResponseContext<T>(dataKey),
62605
+ ...usePaginatedResponseContext<T>(dataKey, options),
62080
62606
  };
62081
62607
 
62082
62608
  provideLocal(toTableKey(dataKey), context);
@@ -62088,7 +62614,10 @@ Gotchas:
62088
62614
 
62089
62615
  - The key is a rendezvous string (use the page-prop name, e.g.
62090
62616
  `'invoices'`). Like the library's table context, a typo doesn't error —
62091
- it silently creates a second, orphan context.
62617
+ it silently creates a second, orphan context. So does a sibling: call it
62618
+ in the page (or a parent of every reader) first.
62619
+ - The selection outlives a filter change (it is cross-page by design). Call
62620
+ `resetSelection()` where the filter changes when the old picks must go.
62092
62621
  - Each composite context provides under a namespaced key
62093
62622
  (`invoices:table`, `invoices:selection`, `invoices:paginatedResponse`),
62094
62623
  so the pieces can also be joined individually.
@@ -62122,9 +62651,14 @@ submit, chain a toast, or hand the promise to a `BbButton` `@click` /
62122
62651
  Inertia form — `form.errors`, `form.isDirty`, `form.reset()` all work.
62123
62652
 
62124
62653
  The promise resolves `true` when the server answered and `false` when the visit
62125
- was cancelled (a `before` listener such as `useConfirmOnRouteLeave`, `cancel()`)
62126
- or interrupted by a newer one: nothing was saved, so skip the success toast and
62127
- the re-baseline (`if (!(await put(url))) return;`) and the form stays dirty.
62654
+ was cancelled (its own `onBefore` returning false, a `before` listener such as
62655
+ `useConfirmOnRouteLeave`, `cancel()`) or interrupted by a newer one: nothing was
62656
+ saved, so skip the success toast and the re-baseline
62657
+ (`if (!(await put(url))) return;`) and the form stays dirty. A save the server
62658
+ answers with a redirect to a `#fragment` (Inertia's 409 follow-up visit) or with
62659
+ `Inertia::location()` resolves `true`; an Inertia-rendered error page (a 403 /
62660
+ 500 with your custom error component) rejects. Your own `onSuccess` / `onError`
62661
+ / `onFinish` options still run.
62128
62662
 
62129
62663
  **Declare the draft shape as a `type` alias, not an `interface`.** The generic
62130
62664
  is constrained to `Record<string, FormValue>` (what Inertia can serialize), and
@@ -62137,6 +62671,7 @@ TypeScript quirk, not a statement about your data.
62137
62671
  // inertia-helpers/usePromiseForm.ts
62138
62672
  import { useLoading } from './useLoading';
62139
62673
  import {
62674
+ router,
62140
62675
  useForm,
62141
62676
  type InertiaForm,
62142
62677
  type InertiaFormProps,
@@ -62154,6 +62689,17 @@ type FormValue =
62154
62689
 
62155
62690
  type Options = Parameters<InertiaFormProps<object>['get']>[1];
62156
62691
 
62692
+ // Inertia 3's per-visit failure callbacks. Inertia 2 has none (it ignores
62693
+ // them), only the global `invalid` / `exception` events, heard below.
62694
+ type FailureCallbacks = {
62695
+ onHttpException?: (response: { status: number }) => boolean | void;
62696
+ onNetworkError?: (error: unknown) => boolean | void;
62697
+ };
62698
+ const onRouterEvent = (
62699
+ name: string,
62700
+ listener: (event: CustomEvent) => void
62701
+ ): (() => void) => router.on(name as 'start', listener as never);
62702
+
62157
62703
  export const usePromiseForm = <T extends Record<string, FormValue>>(
62158
62704
  initialData: T
62159
62705
  ) => {
@@ -62168,34 +62714,129 @@ export const usePromiseForm = <T extends Record<string, FormValue>>(
62168
62714
  ): Promise<boolean> =>
62169
62715
  // true: the server answered and the save went through. false: the
62170
62716
  // visit was cancelled or interrupted, nothing to report. Rejects with
62171
- // the error bag, or for a response that is not Inertia's.
62172
- new Promise<boolean>((resolve, reject) => {
62173
- const { onFinish: userOnFinish, ...visitOptions } = options;
62717
+ // the error bag, or for an error page / a response that is not Inertia's.
62718
+ new Promise<boolean>((resolvePromise, rejectPromise) => {
62719
+ const {
62720
+ onBefore,
62721
+ onSuccess,
62722
+ onError,
62723
+ onHttpException,
62724
+ onNetworkError,
62725
+ onFinish,
62726
+ ...visitOptions
62727
+ } = options as NonNullable<Options> & FailureCallbacks;
62728
+ // Visits sent with these callbacks, and how many finished: a save
62729
+ // answered with a 409 redirect (a redirect with a #fragment) is
62730
+ // re-sent by Inertia itself with the same callbacks, and that
62731
+ // follow-up interrupts the first visit. The last one settles.
62732
+ let sent = 0;
62733
+ let finished = 0;
62734
+ let awaitingBefore = false;
62735
+ let failedStatus: number | null = null;
62736
+ // Inertia 2: a non-Inertia response or a network error is a global
62737
+ // event only, fired before the visit finishes. Heard while this
62738
+ // promise is open (an async reload failing meanwhile counts too).
62739
+ const unbindFailures = [
62740
+ onRouterEvent('invalid', ({ detail }) => {
62741
+ failedStatus = (detail as { response: { status: number } }).response
62742
+ .status;
62743
+ }),
62744
+ onRouterEvent('exception', () => void (failedStatus = 0)),
62745
+ ];
62746
+ const settle = () => {
62747
+ unbind();
62748
+ unbindFailures.forEach((unbindFailure) => unbindFailure());
62749
+ };
62750
+ const resolve = (value: boolean) => {
62751
+ settle();
62752
+ resolvePromise(value);
62753
+ };
62754
+ const reject = (error: unknown) => {
62755
+ settle();
62756
+ rejectPromise(error);
62757
+ };
62758
+ // A visit stopped before it starts (its own onBefore returning
62759
+ // false, below, or a `before` listener that refuses it) fires no
62760
+ // callback at all: no save, resolve false. Registered last, this
62761
+ // listener sees what the ones before it decided.
62762
+ const unbind = router.on('before', (event) => {
62763
+ if (!awaitingBefore) return;
62764
+ awaitingBefore = false;
62765
+ if (!event.defaultPrevented) return;
62766
+ // Held by useConfirmOnRouteLeave's dialog: "Discard changes"
62767
+ // re-sends it with these callbacks, "Keep editing" finishes it
62768
+ // as cancelled. Either one settles this promise.
62769
+ if (
62770
+ (event.detail.visit as { awaitingConfirm?: boolean })
62771
+ .awaitingConfirm
62772
+ )
62773
+ return;
62774
+ if (--sent === finished) resolve(false);
62775
+ });
62174
62776
 
62175
62777
  form[method](url, {
62176
62778
  ...visitOptions,
62177
- onSuccess: () => resolve(true),
62178
- onError: (errors: unknown) => reject(errors),
62779
+ onBefore: (visit) => {
62780
+ const answer = onBefore?.(visit);
62781
+ if (answer === false) {
62782
+ if (sent === finished) resolve(false);
62783
+ } else {
62784
+ sent++;
62785
+ awaitingBefore = true;
62786
+ }
62787
+ return answer;
62788
+ },
62789
+ onSuccess: (page) => {
62790
+ onSuccess?.(page);
62791
+ // An error page Inertia renders (a 403 / 500 with `x-inertia`)
62792
+ // lands like a success: it is not a save.
62793
+ if (failedStatus === null) resolve(true);
62794
+ else reject(new Error(`Inertia visit answered ${failedStatus}.`));
62795
+ },
62796
+ onError: (errors) => {
62797
+ onError?.(errors);
62798
+ reject(errors);
62799
+ },
62800
+ ...({
62801
+ onHttpException: (response) => {
62802
+ failedStatus = response.status;
62803
+ return onHttpException?.(response);
62804
+ },
62805
+ onNetworkError: (error) => {
62806
+ failedStatus = 0;
62807
+ return onNetworkError?.(error);
62808
+ },
62809
+ } satisfies FailureCallbacks),
62179
62810
  onFinish: (
62180
62811
  ...args: Parameters<NonNullable<NonNullable<Options>['onFinish']>>
62181
62812
  ) => {
62182
- userOnFinish?.(...args);
62183
- // A cancelled visit (`cancel()`, a `before` listener such as
62184
- // useConfirmOnRouteLeave's "Keep editing") or one a newer visit
62813
+ onFinish?.(...args);
62814
+ finished++;
62815
+ // A cancelled visit (`cancel()`) or one a newer visit
62185
62816
  // interrupted is no failure, and no save either: it reaches
62186
- // only onFinish. Resolve false.
62817
+ // only onFinish. Resolve false, unless it is Inertia's own
62818
+ // 409 follow-up that interrupted it: that one settles.
62187
62819
  const [visit] = args as unknown as [
62188
62820
  { cancelled?: boolean; interrupted?: boolean } | undefined,
62189
62821
  ];
62190
62822
  if (visit?.cancelled || visit?.interrupted) {
62191
- resolve(false);
62823
+ if (sent <= finished) resolve(false);
62192
62824
  return;
62193
62825
  }
62194
62826
  // A non-Inertia response (e.g. a 423 entity lock or a 419
62195
- // CSRF mismatch) fires neither onSuccess nor onError, only
62196
- // onFinish. Settle here so isSubmitting is released instead
62197
- // of hanging the form forever. No-op once already settled.
62198
- reject(new Error('Inertia visit did not return a valid response.'));
62827
+ // CSRF mismatch) or a network error fires neither onSuccess
62828
+ // nor onError, only onFinish. Settle here so isSubmitting is
62829
+ // released instead of hanging the form forever. No-op once
62830
+ // already settled.
62831
+ if (failedStatus !== null) {
62832
+ reject(
62833
+ new Error('Inertia visit did not return a valid response.')
62834
+ );
62835
+ return;
62836
+ }
62837
+ // `Inertia::location()` (an external redirect after the save)
62838
+ // fires only onFinish too, while the browser leaves.
62839
+ resolve(true);
62199
62840
  },
62200
62841
  });
62201
62842
  })
@@ -62225,6 +62866,26 @@ export const usePromiseForm = <T extends Record<string, FormValue>>(
62225
62866
  > have nothing else to do on rejection. Every call site in this guide and its
62226
62867
  > recipes follows one of those two shapes — copy them, not a bare `post(...)`.
62227
62868
 
62869
+ **A summary line over the form is the page's, not the helper's.** The
62870
+ rejection tells the two failures apart: the error bag (an object, already in
62871
+ `form.errors`) or an `Error` (the server answered with something that is not
62872
+ Inertia's). Word them in the `catch`:
62873
+
62874
+ ```ts
62875
+ const failure = ref('');
62876
+ const save = async () => {
62877
+ failure.value = '';
62878
+ try {
62879
+ await put(`/invoices/${invoice.id}`);
62880
+ } catch (error) {
62881
+ failure.value =
62882
+ error instanceof Error
62883
+ ? 'The save did not go through. Try again.'
62884
+ : 'Check the highlighted fields.';
62885
+ }
62886
+ };
62887
+ ```
62888
+
62228
62889
  **Seeding a draft: `NullableKeys<T>`, not a hand-rolled `| null` on every
62229
62890
  field.** Don't declare a bespoke `Draft` type that bolts `| null` onto each
62230
62891
  string prop by hand — that's what produces empty-string drafts (`''`
@@ -62269,57 +62930,86 @@ const { form } = usePromiseForm<NullableKeys<InvoiceRequest>>({
62269
62930
 
62270
62931
  Deep dirty-checking against a snapshot baseline — for edit screens whose
62271
62932
  state is not a single Inertia form (multiple forms, ad-hoc refs). For a
62272
- single `usePromiseForm`, prefer the built-in `form.isDirty`.
62933
+ single `usePromiseForm`, prefer the built-in `form.isDirty`. The baseline is
62934
+ a structural hash of each value, so Dates, Sets, Maps, `undefined` and NaN
62935
+ compare as they are, and a `File` by its name, size, type and date, so picking
62936
+ another file over a picked one counts.
62273
62937
 
62274
62938
  ```ts
62275
62939
  // inertia-helpers/useHasChanged.ts
62276
- import { isEqual } from './utils';
62940
+ import { isPlainObject } from './utils';
62941
+ import { hash } from 'object-code';
62277
62942
  import { computed, ref, toValue, type WatchSource } from 'vue';
62278
62943
 
62279
62944
  export function useHasChanged<T extends WatchSource<unknown>[]>(
62280
62945
  dependencies: T
62281
62946
  ) {
62282
62947
  /*
62283
- * Baselines are JSON round-trips, NOT `structuredClone`. This composable's
62284
- * documented inputs are refs and reactive objects, and `structuredClone`
62285
- * throws `DataCloneError` on every one of them — a Vue proxy is not a
62286
- * structured-cloneable type. It took the page down at setup.
62287
- *
62288
- * The comparison needs no copy at all: `isEqual` is hash-based and reads
62289
- * straight through a proxy, so the live value can be hashed as-is. Only the
62290
- * BASELINE needs detaching from live state, which is what the round-trip is
62291
- * for.
62948
+ * The baseline is a structural HASH, not a copy. A copy has to survive the
62949
+ * values a form holds: a JSON round-trip turns a Date into a string, a File,
62950
+ * Set or Map into `{}`, drops `undefined` (and throws on a bare one), so
62951
+ * an untouched form read as changed; `structuredClone` throws on Vue proxies.
62952
+ * `object-code` hashes straight through a proxy and keeps Dates (by time),
62953
+ * Sets / Maps (by entries), `undefined` and NaN apart, and a number is all
62954
+ * the baseline needs to hold. It hashes every File alike (no own keys), so
62955
+ * a File is read by name, size, type and date first: picking another file
62956
+ * over a picked one counts as a change.
62292
62957
  */
62293
62958
  const snapshot = (dep: WatchSource<unknown>) =>
62294
- JSON.parse(JSON.stringify(toValue(dep)));
62959
+ hash(withFileFingerprints(toValue(dep)));
62295
62960
 
62296
- const baselineValues = ref<unknown[]>(dependencies.map(snapshot));
62961
+ const baselines = ref<number[]>(dependencies.map(snapshot));
62297
62962
 
62298
62963
  /** Re-baseline (call after a successful save). */
62299
62964
  const takeSnapshot = (): void => {
62300
- baselineValues.value = dependencies.map(snapshot);
62965
+ baselines.value = dependencies.map(snapshot);
62301
62966
  };
62302
62967
 
62303
62968
  const hasChanged = computed(() =>
62304
- dependencies.some(
62305
- (dep, index) => !isEqual(toValue(dep), baselineValues.value[index])
62306
- )
62969
+ dependencies.some((dep, index) => snapshot(dep) !== baselines.value[index])
62307
62970
  );
62308
62971
 
62309
62972
  return { hasChanged, takeSnapshot };
62310
62973
  }
62974
+
62975
+ const withFileFingerprints = (value: unknown): unknown =>
62976
+ value instanceof Blob
62977
+ ? [
62978
+ value.size,
62979
+ value.type,
62980
+ (value as Partial<File>).name,
62981
+ (value as Partial<File>).lastModified,
62982
+ ]
62983
+ : Array.isArray(value)
62984
+ ? value.map(withFileFingerprints)
62985
+ : isPlainObject(value)
62986
+ ? Object.fromEntries(
62987
+ Object.entries(value).map(([key, item]) => [
62988
+ key,
62989
+ withFileFingerprints(item),
62990
+ ])
62991
+ )
62992
+ : value;
62311
62993
  ```
62312
62994
 
62313
62995
  ### useConfirmOnRouteLeave
62314
62996
 
62315
62997
  A dirty-state navigation guard: intercepts Inertia visits and asks through
62316
62998
  [`useConfirm`](https://llms-bitboss-ui.bitboss.it/dist/ai/composables/useConfirm.md) before leaving. Requires
62317
- `<BbHost />` mounted in the app root. Server redirects after a
62318
- whitelisted action carry the `X-Whitelisted-Action` header so a saving
62319
- visit's redirect is never blocked by its own guard.
62999
+ `<BbHost />` mounted in the app root. `whitelist` names the form's own
63000
+ submit targets: a submit (POST / PUT / PATCH / DELETE) to one of them goes
63001
+ out without asking, while a plain link to the same path (a Cancel button
63002
+ back to `/invoices`) still asks. Server redirects after a whitelisted
63003
+ action carry the `X-Whitelisted-Action` header so a saving visit's redirect
63004
+ is never blocked by its own guard. Leaving the app (closing the tab,
63005
+ reloading, a typed URL, a plain `<a>`) gets the browser's own "Leave site?"
63006
+ prompt while the guard is armed; a whitelisted save that the server answers
63007
+ with `Inertia::location()` leaves without one. `stack` picks the `BbDialog`
63008
+ stack the dialog joins; `variant` defaults to `'destructive'`.
62320
63009
 
62321
63010
  ```ts
62322
63011
  // inertia-helpers/useConfirmOnRouteLeave.ts
63012
+ import { useHelperMessages } from './messages';
62323
63013
  import { router } from '@inertiajs/vue3';
62324
63014
  import { useConfirm, type BbConfirmMessage } from 'bitboss-ui';
62325
63015
  import { onScopeDispose, toValue, type MaybeRefOrGetter } from 'vue';
@@ -62333,7 +63023,11 @@ type Visit = {
62333
63023
  };
62334
63024
 
62335
63025
  type Options = {
62336
- /** URLs that bypass the dialog (e.g. the form's own submit target). */
63026
+ /**
63027
+ * Submit targets that bypass the dialog (the form's own save). Only a
63028
+ * submit (POST / PUT / PATCH / DELETE) passes: a link to the same path
63029
+ * (Cancel → the list) still asks.
63030
+ */
62337
63031
  whitelist?: MaybeRefOrGetter<string[]>;
62338
63032
  /** Disable the guard (bind to `!hasChanged` / `!form.isDirty`). */
62339
63033
  disabled?: MaybeRefOrGetter<boolean>;
@@ -62345,12 +63039,14 @@ type Options = {
62345
63039
  text?: string;
62346
63040
  /**
62347
63041
  * The dialog's buttons and look, BbConfirm's own options: a label string
62348
- * or a full button config. Defaults: `'Discard changes'` / `'Keep editing'`,
62349
- * `variant: 'destructive'`. Pass translated labels in a non-English app.
63042
+ * or a full button config. Default words: `leave` in `messages.ts`, in the
63043
+ * library's `locale`; `variant: 'destructive'`.
62350
63044
  */
62351
63045
  yes?: BbConfirmMessage['yes'];
62352
63046
  no?: BbConfirmMessage['no'];
62353
63047
  variant?: BbConfirmMessage['variant'];
63048
+ /** The `BbDialog` stack the dialog joins (BbConfirm's `stack`). */
63049
+ stack?: BbConfirmMessage['stack'];
62354
63050
  };
62355
63051
 
62356
63052
  const normalizeUrl = (value: string | URL) => {
@@ -62377,24 +63073,26 @@ export const useConfirmOnRouteLeave = ({
62377
63073
  disabled = false,
62378
63074
  onBefore = () => true,
62379
63075
  onLeave = () => true,
62380
- title = 'Unsaved changes',
62381
- text = 'You have unsaved changes. Leave without saving?',
62382
- yes = 'Discard changes',
62383
- no = 'Keep editing',
63076
+ title,
63077
+ text,
63078
+ yes,
63079
+ no,
62384
63080
  variant = 'destructive',
63081
+ stack,
62385
63082
  }: Options = {}) => {
62386
63083
  // Captured in setup so the built-in dialog is owned by the guarded page:
62387
63084
  // if the page unmounts anyway, the dialog dismisses itself quietly.
62388
63085
  const { confirm } = useConfirm();
63086
+ const messages = useHelperMessages();
62389
63087
  let bypassNextVisit = false;
62390
63088
 
62391
63089
  const unbind = router.on('before', (event) => {
62392
63090
  const visit = event.detail.visit as Visit;
62393
63091
 
62394
63092
  // A hover / mount prefetch (`<Link prefetch>`, `<BbButton href prefetch>`)
62395
- // fires `before` too, but leaves nothing: no dialog, and it must not use up
62396
- // the bypass the re-sent visit below is waiting for.
62397
- if (visit.prefetch) return;
63093
+ // fires `before` too, but leaves nothing: no dialog. A visit another
63094
+ // guard on the page already stopped is that guard's to ask about.
63095
+ if (visit.prefetch || event.defaultPrevented) return;
62398
63096
 
62399
63097
  if (bypassNextVisit) {
62400
63098
  bypassNextVisit = false;
@@ -62406,41 +63104,146 @@ export const useConfirmOnRouteLeave = ({
62406
63104
 
62407
63105
  const nextPath = normalizeUrl(visit.url.href);
62408
63106
  if (nextPath === normalizeUrl(window.location.href)) return;
62409
- if (toValue(whitelist).map(normalizeUrl).includes(nextPath)) return;
63107
+ if (
63108
+ visit.method !== 'get' &&
63109
+ toValue(whitelist).map(normalizeUrl).includes(nextPath)
63110
+ ) {
63111
+ // Marked: a save answered with a 409 redirect (a redirect with a
63112
+ // #fragment) is re-sent by Inertia with these same headers, and that
63113
+ // follow-up must not ask either.
63114
+ visit.headers = { ...visit.headers, [WHITELIST_HEADER]: '1' };
63115
+ return;
63116
+ }
62410
63117
 
62411
63118
  // `return`, not `return false`: an Inertia `before` listener that returns
62412
63119
  // false CANCELS the visit, which is the opposite of what `onBefore`
62413
63120
  // promises (false = let this visit through, no dialog).
62414
63121
  if (onBefore(visit) === false) return;
62415
63122
  event.preventDefault();
63123
+ // Held, not refused: the dialog decides. `usePromiseForm` reads this
63124
+ // flag and waits instead of answering `false` at once.
63125
+ visit.awaitingConfirm = true;
62416
63126
 
62417
63127
  void (async () => {
62418
- const confirmed = await confirm({ variant, title, text, yes, no });
62419
- if (!confirmed) return;
63128
+ const words = messages.value.leave;
63129
+ const confirmed = await confirm({
63130
+ title: title ?? words.title,
63131
+ text: text ?? words.text,
63132
+ yes: yes ?? words.yes,
63133
+ no: no ?? words.no,
63134
+ variant,
63135
+ ...(stack && { stack }),
63136
+ });
63137
+ if (!confirmed) {
63138
+ // "Keep editing" ends the held visit the way Inertia ends a
63139
+ // cancelled one, so its own callbacks (a form's processing state,
63140
+ // a pending promise) settle.
63141
+ const callbacks = visit as {
63142
+ onCancel?: () => void;
63143
+ onFinish?: (visit: Visit) => void;
63144
+ };
63145
+ callbacks.onCancel?.();
63146
+ callbacks.onFinish?.({ ...visit, cancelled: true });
63147
+ return;
63148
+ }
62420
63149
 
62421
63150
  await onLeave(visit);
63151
+ // Re-sent as a plain visit: never as a prefetch or a background one,
63152
+ // and without the visit's own onBefore (its `confirm('Sure?')` was
63153
+ // already answered).
63154
+ const {
63155
+ prefetch: _prefetch,
63156
+ async: _async,
63157
+ onBefore: _onBefore,
63158
+ awaitingConfirm: _awaitingConfirm,
63159
+ ...rest
63160
+ } = visit;
63161
+ // `before` fires inside router.visit(): the bypass covers exactly
63162
+ // that visit, and is never left armed for the next one (a re-send
63163
+ // its own callbacks stop before `before`).
62422
63164
  bypassNextVisit = true;
62423
- // Re-sent as a plain visit: never as a prefetch or a background one.
62424
- const { prefetch: _prefetch, async: _async, ...rest } = visit;
62425
63165
  router.visit(visit.url, {
62426
63166
  ...(rest as Parameters<typeof router.visit>[1]),
62427
63167
  headers: { ...visit.headers, [WHITELIST_HEADER]: '1' },
62428
63168
  });
63169
+ bypassNextVisit = false;
62429
63170
  })();
62430
63171
  });
62431
63172
 
63173
+ // Leaving the app (closing the tab, reloading, a typed URL, a plain `<a>`)
63174
+ // never reaches `before`: the browser's own prompt covers it. Not for a
63175
+ // visit this guard let through: a save answered with `Inertia::location()`
63176
+ // is a real unload with the form still dirty. Inertia sets
63177
+ // `window.location` inside that visit, and WebKit asks only after the visit
63178
+ // finished, so a let-through visit that completed with no answer (no page,
63179
+ // no error bag, no error response: Inertia left) keeps it quiet until the
63180
+ // next visit starts.
63181
+ const released = new Set<unknown>();
63182
+ let answered = false;
63183
+ let leaving = false;
63184
+ const marked = (visit: { headers?: Record<string, string> }) =>
63185
+ getHeaderValue(visit.headers, WHITELIST_HEADER) === '1';
63186
+ const unbindStart = router.on('start', ({ detail: { visit } }) => {
63187
+ leaving = false;
63188
+ answered = false;
63189
+ if (marked(visit)) released.add(visit);
63190
+ });
63191
+ // Every way a visit gets an answer; the last two pairs are Inertia 3's
63192
+ // names and Inertia 2's (each fires only on its own version).
63193
+ const onRouterEvent = (name: string, listener: () => void) =>
63194
+ router.on(name as 'start', listener);
63195
+ const unbindAnswers = [
63196
+ 'success',
63197
+ 'error',
63198
+ 'httpException',
63199
+ 'invalid',
63200
+ 'networkError',
63201
+ 'exception',
63202
+ ].map((name) => onRouterEvent(name, () => void (answered = true)));
63203
+ const unbindFinish = router.on('finish', ({ detail: { visit } }) => {
63204
+ released.delete(visit);
63205
+ if (marked(visit) && visit.completed && !answered) leaving = true;
63206
+ });
63207
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
63208
+ if (toValue(disabled) || released.size || leaving) return;
63209
+ event.preventDefault();
63210
+ event.returnValue = '';
63211
+ };
63212
+ const inBrowser = typeof window !== 'undefined';
63213
+ if (inBrowser) window.addEventListener('beforeunload', onBeforeUnload);
63214
+
62432
63215
  // `router.on` is a global bus that outlives this component: unbind it.
62433
63216
  // `onScopeDispose` over `onUnmounted`: it fires on unmount too (`setup()`
62434
63217
  // runs inside a component's own effect scope), but also works if this
62435
63218
  // composable is ever called from outside one.
62436
- onScopeDispose(unbind);
63219
+ onScopeDispose(() => {
63220
+ unbind();
63221
+ unbindStart();
63222
+ unbindAnswers.forEach((unbindAnswer) => unbindAnswer());
63223
+ unbindFinish();
63224
+ if (inBrowser) window.removeEventListener('beforeunload', onBeforeUnload);
63225
+ });
62437
63226
  };
62438
63227
  ```
62439
63228
 
62440
63229
  A prefetch never asks: `<Link prefetch>` and `<BbButton href prefetch>` fire
62441
63230
  Inertia's `before` event on hover too, and the guard lets them through (they
62442
63231
  leave nothing). The visit "Discard changes" re-sends is a plain one, never a
62443
- prefetch or an `async` visit.
63232
+ prefetch or an `async` visit, and it does not ask the visit's own `onBefore`
63233
+ (an `onBefore: () => confirm('Sure?')`) a second time. "Keep editing" ends
63234
+ the held visit as Inertia ends a cancelled one (`onCancel`, then `onFinish`
63235
+ with `cancelled: true`), so a `usePromiseForm` save held by the dialog
63236
+ answers the user's choice: the re-sent save's result, or `false`. Two guards
63237
+ on one page ask once. **Back / Forward are not guarded, by design:** Inertia restores
63238
+ those pages from history without a `before` event, and catching them would
63239
+ mean fighting Inertia's own history and scroll restore.
63240
+
63241
+ **The dialog speaks the library's `locale`.** Its default words come from
63242
+ `messages.ts` (see [Shared types and utilities](#shared-types-and-utilities)),
63243
+ which ships English only. Add your app's locale there once, keyed as you set
63244
+ the library's `locale`, and every guard follows `setConfig({ locale })`; a
63245
+ locale with no entry falls back to English. `title` / `text` / `yes` / `no`
63246
+ passed to one guard still win over it.
62444
63247
 
62445
63248
  > **`router.on(...)` subscribes to a global, module-level event bus — it
62446
63249
  > outlives the component that registered it unless you unbind it.** Inertia
@@ -62628,13 +63431,22 @@ export function useAsyncFn<T>(
62628
63431
  "Given an id, keep its resource loaded": re-fetches whenever the id
62629
63432
  changes, resets to `null` when the id clears, exposes `loading` and
62630
63433
  `refresh()`. The natural engine for a detail sidebar or peek panel — feed
62631
- it the highlighted row's id.
63434
+ it the highlighted row's id. `fn` gets an `AbortSignal` as its second argument
63435
+ (`(id, signal) => fetch(url, { signal })`): it aborts when the load goes stale
63436
+ (a newer id, a clear, the page unmounting).
62632
63437
 
62633
63438
  ```ts
62634
63439
  // inertia-helpers/useResourceContext.ts
62635
63440
  import { useLoading } from './useLoading';
62636
63441
  import { noop } from './utils';
62637
- import { ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue';
63442
+ import {
63443
+ onScopeDispose,
63444
+ ref,
63445
+ toValue,
63446
+ watch,
63447
+ type MaybeRefOrGetter,
63448
+ type Ref,
63449
+ } from 'vue';
62638
63450
 
62639
63451
  type UseResourceContextOptions<T, ID> = {
62640
63452
  immediate?: boolean;
@@ -62647,7 +63459,8 @@ type UseResourceContextOptions<T, ID> = {
62647
63459
 
62648
63460
  export function useResourceContext<T, ID = string>(
62649
63461
  id: MaybeRefOrGetter<ID | null | undefined>,
62650
- fn: (id: ID) => Promise<T>,
63462
+ /** `signal` aborts when the load goes stale: pass it to `fetch` / axios. */
63463
+ fn: (id: ID, signal: AbortSignal) => Promise<T>,
62651
63464
  options: UseResourceContextOptions<T, ID> = {}
62652
63465
  ) {
62653
63466
  const data = ref<T | null>(null) as Ref<T | null>;
@@ -62661,31 +63474,69 @@ export function useResourceContext<T, ID = string>(
62661
63474
  onFinish = noop,
62662
63475
  } = options;
62663
63476
 
62664
- const { loading, fn: fetch } = useLoading((resourceId: ID) => fn(resourceId));
63477
+ const { loading, fn: fetch } = useLoading(
63478
+ (resourceId: ID, signal: AbortSignal) => fn(resourceId, signal)
63479
+ );
63480
+
63481
+ // Each load takes a ticket; a newer load, an id change or the scope's end
63482
+ // makes older tickets stale, and a stale response is dropped (no data, no
63483
+ // callbacks): a slow answer for the row the user left never lands. Going
63484
+ // stale also aborts its request.
63485
+ let ticket = 0;
63486
+ let controller: AbortController | null = null;
63487
+ const stale = () => {
63488
+ ticket++;
63489
+ controller?.abort();
63490
+ };
63491
+ let loadedId: ID | null = null;
63492
+ const clear = () => {
63493
+ stale();
63494
+ data.value = null;
63495
+ loadedId = null;
63496
+ };
63497
+ onScopeDispose(stale);
62665
63498
 
62666
63499
  const load = async (resourceId: ID) => {
63500
+ stale();
63501
+ const own = ticket;
63502
+ controller = new AbortController();
63503
+ const outcome = await fetch(resourceId, controller.signal).then(
63504
+ (result) => ({ ok: true as const, result }),
63505
+ (error: unknown) => ({ ok: false as const, error })
63506
+ );
63507
+ if (own !== ticket) return;
62667
63508
  try {
62668
- const result = await fetch(resourceId);
62669
- data.value = result;
62670
- await onSuccess(result, resourceId);
62671
- } catch (error) {
62672
- data.value = null;
62673
- await onError(error, resourceId);
63509
+ if (outcome.ok) {
63510
+ data.value = outcome.result;
63511
+ loadedId = resourceId;
63512
+ // Outside the fetch's catch: a throwing onSuccess is the
63513
+ // caller's bug, not a failed fetch.
63514
+ await onSuccess(outcome.result, resourceId);
63515
+ } else {
63516
+ clear();
63517
+ await onError(outcome.error, resourceId);
63518
+ }
62674
63519
  } finally {
62675
63520
  await onFinish(resourceId);
62676
63521
  }
62677
63522
  };
62678
63523
 
63524
+ // Per-item sources: a re-run that yields the same id and flag (a partial
63525
+ // reload handing a new row object with the same id) does not refetch.
62679
63526
  watch(
62680
- () => ({ resourceId: toValue(id), isDisabled: toValue(disabled) }),
62681
- async ({ resourceId, isDisabled }) => {
63527
+ [() => toValue(id), () => toValue(disabled)],
63528
+ async ([resourceId, isDisabled]) => {
62682
63529
  if (!resourceId) {
62683
- data.value = null;
63530
+ clear();
62684
63531
  await options.onReset?.();
62685
63532
  return;
62686
63533
  }
62687
- // Keep already-loaded data while disabled.
62688
- if (isDisabled) return;
63534
+ // Keep already-loaded data while disabled, but only for the id it
63535
+ // was loaded for: never show row a's resource under row b.
63536
+ if (isDisabled) {
63537
+ if (resourceId !== loadedId) clear();
63538
+ return;
63539
+ }
62689
63540
  await load(resourceId as ID);
62690
63541
  },
62691
63542
  { immediate }
@@ -62715,6 +63566,10 @@ Gotchas:
62715
63566
  never rejects on a non-2xx response — a 403/404 body still parses as JSON
62716
63567
  (or throws inside `.json()`, which is _also_ swallowed by `useResourceContext`
62717
63568
  the same way). Throw when `!res.ok` so `onError` actually fires.
63569
+ - **Only the current id's answer lands.** A slower response for a row the
63570
+ user already left, or one landing after the id cleared or the scope ended,
63571
+ is dropped: no `data`, no callbacks. While `disabled`, `data` is kept only
63572
+ for the id it was loaded for; an id change clears it (re-enabling loads).
62718
63573
 
62719
63574
  ## UI utilities
62720
63575
 
@@ -62759,28 +63614,32 @@ export function useDeleteCmd(
62759
63614
 
62760
63615
  ### useScreenSizes
62761
63616
 
62762
- Shared reactive breakpoints aligned with Tailwind `md`/`lg` — one window
62763
- listener for the whole app via `createSharedComposable`.
63617
+ Shared reactive breakpoints — one window listener for the whole app via
63618
+ `createSharedComposable`. `isMobile` is the library's own signal (below the
63619
+ plugin's `mobileMaxWidth`, 768 by default), so a page and its components
63620
+ agree; tablet ends at Tailwind `lg`.
62764
63621
 
62765
63622
  ```ts
62766
63623
  // inertia-helpers/useScreenSizes.ts
62767
63624
  import { createSharedComposable, useWindowSize } from '@vueuse/core';
63625
+ import { useMobile } from 'bitboss-ui';
62768
63626
  import { computed } from 'vue';
62769
63627
 
62770
- /** Tailwind md breakpoint (px). Mobile when width < 768. */
62771
- export const SCREEN_BREAKPOINT_MD = 768;
62772
- /** Tailwind lg breakpoint (px). Tablet when 768 <= width < 1024. */
63628
+ /** Tailwind lg breakpoint (px). Tablet from the library's mobile cut up to here. */
62773
63629
  export const SCREEN_BREAKPOINT_LG = 1024;
62774
63630
 
62775
63631
  const useScreenSizesImpl = () => {
62776
63632
  const { width } = useWindowSize();
63633
+ // The library's own signal (below the plugin's `mobileMaxWidth`), so a page
63634
+ // and the components on it never disagree about "mobile".
63635
+ const { isMobile } = useMobile();
62777
63636
 
62778
- const isMobile = computed(() => width.value < SCREEN_BREAKPOINT_MD);
62779
63637
  const isTablet = computed(
62780
- () =>
62781
- width.value >= SCREEN_BREAKPOINT_MD && width.value < SCREEN_BREAKPOINT_LG
63638
+ () => !isMobile.value && width.value < SCREEN_BREAKPOINT_LG
63639
+ );
63640
+ const isDesktop = computed(
63641
+ () => !isMobile.value && width.value >= SCREEN_BREAKPOINT_LG
62782
63642
  );
62783
- const isDesktop = computed(() => width.value >= SCREEN_BREAKPOINT_LG);
62784
63643
 
62785
63644
  return { width, isMobile, isTablet, isDesktop };
62786
63645
  };
@@ -72408,6 +73267,54 @@ Nothing here fails to compile — these change what existing code does.
72408
73267
  stay open across picks (a settings panel of several radios): set
72409
73268
  `:close-on-select="false"`; `true` closes after every change, a
72410
73269
  multiple pick included.
73270
+ 113. **`useQueryObject` takes Inertia's visit options in one place** (after
73271
+ 3.0.0-beta.64, the Inertia helper, after `npx bitboss-ui helpers
73272
+ update`). Move `only`, `preserveState`, `preserveScroll` and `replace`
73273
+ into `visitOptions: { only: ['invoices'] }`. `shouldNavigate: () => !x`
73274
+ becomes `paused: () => x` (inverted); resuming now sends the object's
73275
+ current state once, so a page that reassigned the object to catch up
73276
+ after its own visit can drop that. `resetPage: true, resetKeys: [k]`
73277
+ becomes `resetKeys: ['page', k]`: the default is `['page']` and a
73278
+ passed list replaces it. `transform` is gone (pass a getter that builds
73279
+ the keys) and so is `arrayFormat` (`QUERY_ARRAY_FORMAT` in
73280
+ `useQuery.ts`, one value for every helper). `server: false` writes the
73281
+ URL with no request.
73282
+ 114. **The URL helpers read and write the URL as the server does** (after
73283
+ 3.0.0-beta.64, `useQuery` / `useRouteQuery`, after a helpers update).
73284
+ `useQuery` decodes once: `?q=R%26D` reads `R&D`, and an encoded `%2C`
73285
+ separates a comma array as PHP and Spatie read it (with the comma
73286
+ format a comma typed into free text reads back as a separator, as on
73287
+ the server). Writes made before the server answers build on each other
73288
+ (sort and direction in one click both land), and a write changes only
73289
+ its own key. `useRouteQuery`: move `preserveScroll` / `preserveState`
73290
+ into `visitOptions`; `null` / `undefined` now remove the key (it used
73291
+ to write the string `'null'`); a repeated key reads its last value.
73292
+ 115. **The pager pushes history, and the visit helpers settle** (after
73293
+ 3.0.0-beta.64, after a helpers update). A `page` write is a history
73294
+ entry (Back goes to the previous page; `visitOptions: { replace: true }`
73295
+ keeps the old behaviour); a new `perPage` returns to page 1 in the same
73296
+ visit; a page past the last goes to the last. `items` is a copy that a
73297
+ visit asking for the prop resets, while other prop swaps keep its edits
73298
+ (this replaces item 102's rule). New options `pageQueryKey` /
73299
+ `perPageQueryKey` for a custom Laravel `pageName`. `usePromiseForm`
73300
+ resolves `false` when a guard cancels and rejects an Inertia error page;
73301
+ `useConfirmOnRouteLeave`'s `whitelist` now only lets submits through, so
73302
+ a Cancel link to the list asks.
73303
+ 116. **The leave guard asks the browser too, and dirty checks stop crying
73304
+ wolf** (after 3.0.0-beta.64, after a helpers update).
73305
+ `useConfirmOnRouteLeave` now also gets the browser's "Leave site?"
73306
+ prompt on a tab close, reload, typed URL or plain `<a>` (not for a
73307
+ whitelisted save that leaves through `Inertia::location()`), takes
73308
+ `stack`, and takes its words from the new `messages.ts` in the library's
73309
+ `locale` (add your locale there instead of passing labels per page). `useHasChanged` no longer reads an untouched form with Dates,
73310
+ Files or `undefined` as changed. `items` in the paginated helpers is a
73311
+ deep copy, so editing a row leaves the page prop alone. `useScreenSizes`
73312
+ follows the plugin's `mobileMaxWidth`; import `useMobile` from
73313
+ `bitboss-ui` where you used `SCREEN_BREAKPOINT_MD`.
73314
+ 117. **Inertia 2 or 3** (3.0.0-beta.65). The optional `@inertiajs/vue3` peer
73315
+ now starts at 2.0.0. The Inertia helpers run and type-check on both
73316
+ majors; on Inertia 2, `useLazyProp` cannot keep the page's validation
73317
+ errors across its reload.
72411
73318
 
72412
73319
  ## 7. Class overrides → variants
72413
73320
 
@@ -74747,8 +75654,10 @@ part accepts a function** on the instance, beside a plain value:
74747
75654
  - **The row's facts ride beside them.** Today that is `variant`, a typed
74748
75655
  string, where the rows have one: a dropdown entry's own (`default` when it
74749
75656
  has none), a toast's, or the component's (`BbTabs`). On `BbSelect` /
74750
- `BbSelectPopover` it is `known`: `true` for a `known-items` stand-in, whose
74751
- `item` is then `{ value, label }`, as in the `option` slot. Other props are
75657
+ `BbSelectPopover` it is `known`: `true` for a `known-items` stand-in (a row
75658
+ the form already held, named before the real options load; see the
75659
+ [Fetch items playbook](https://llms-bitboss-ui.bitboss.it/dist/ai/guides/fetch-items-playbook.md)), whose `item` is then
75660
+ `{ value, label }`, as in the `option` slot. Other props are
74752
75661
  not in it: you wrote the function where you pass them.
74753
75662
  - The result is a normal `PtValue` (a class list or `{ class, style, attrs }`);
74754
75663
  `false` / `null` / `undefined` mean _leave the row as it is_.
@@ -78042,7 +78951,7 @@ export function provideApprovalsInbox(): ApprovalsInboxContext {
78042
78951
  });
78043
78952
  // The search waits for typing to settle; every other filter visits at once.
78044
78953
  useQueryObject(filters, {
78045
- only: [...RELOAD_PROPS],
78954
+ visitOptions: { only: [...RELOAD_PROPS] },
78046
78955
  debounce: { search: 300 },
78047
78956
  });
78048
78957
 
@@ -79929,7 +80838,7 @@ export function provideBacklogWorkspace(): BacklogWorkspaceContext {
79929
80838
  : String(filters.assignee.id ?? 'none'),
79930
80839
  }),
79931
80840
  // The search waits for typing to settle; every other filter visits at once.
79932
- { only: [...RELOAD_PROPS], debounce: { search: 300 } }
80841
+ { visitOptions: { only: [...RELOAD_PROPS] }, debounce: { search: 300 } }
79933
80842
  );
79934
80843
 
79935
80844
  const hasActiveFilters = computed(
@@ -84272,7 +85181,7 @@ export function provideOwnershipAtlas(): OwnershipAtlasContext {
84272
85181
  });
84273
85182
  // The search waits for typing to settle; every other filter visits at once.
84274
85183
  useQueryObject(filters, {
84275
- only: [...RELOAD_PROPS],
85184
+ visitOptions: { only: [...RELOAD_PROPS] },
84276
85185
  debounce: { search: 300 },
84277
85186
  });
84278
85187
 
@@ -87381,7 +88290,7 @@ export function provideInvoicesWorkspace(): InvoicesWorkspaceContext {
87381
88290
  });
87382
88291
  // The search waits for typing to settle; every other filter visits at once.
87383
88292
  useQueryObject(filters, {
87384
- only: [...RELOAD_PROPS],
88293
+ visitOptions: { only: [...RELOAD_PROPS] },
87385
88294
  debounce: { search: 300 },
87386
88295
  });
87387
88296
 
@@ -92554,7 +93463,7 @@ export function provideUploadCenter(): UploadCenterContext {
92554
93463
  });
92555
93464
  // The search waits for typing to settle; every other filter visits at once.
92556
93465
  useQueryObject(filters, {
92557
- only: [...RELOAD_PROPS],
93466
+ visitOptions: { only: [...RELOAD_PROPS] },
92558
93467
  debounce: { search: 300 },
92559
93468
  });
92560
93469