@quazardous/qdadm 2.17.3 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.17.3",
3
+ "version": "2.18.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  ],
56
56
  "dependencies": {
57
57
  "@quazardous/qdcore": "^1.1.2",
58
- "@quazardous/qddebug": "^1.1.0",
58
+ "@quazardous/qddebug": "^1.2.0",
59
59
  "@quazardous/quarkernel": "^2.1.0",
60
60
  "yaml": "^2.8.4"
61
61
  },
@@ -44,7 +44,6 @@ export interface UseListFiltersDeps {
44
44
  /** Thunks — resolved lazily, the targets are declared later in useListPage. */
45
45
  loadItems: () => void
46
46
  setSearch: (searchCfg: Partial<SearchConfig>) => void
47
- invokeFilterAlterHook: () => Promise<void>
48
47
  }
49
48
 
50
49
  export interface UseListFiltersReturn {
@@ -63,6 +62,8 @@ export interface UseListFiltersReturn {
63
62
  loadFilterOptions: () => Promise<void>
64
63
  updateCacheBasedFilters: () => Promise<void>
65
64
  restoreFilters: () => void
65
+ /** Push filters, search and the page number into the URL (#2113). */
66
+ writeStateToUrl: () => void
66
67
  }
67
68
 
68
69
  export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
@@ -83,13 +84,42 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
83
84
  entityFilters,
84
85
  loadItems,
85
86
  setSearch,
86
- invokeFilterAlterHook,
87
87
  } = deps
88
88
 
89
89
  const filtersMap = ref<Map<string, FilterConfig>>(new Map())
90
90
  const filterValues = ref<Record<string, unknown>>(savedFilters || {})
91
91
 
92
+ /** Names the URL sync already owns — a filter cannot have them (#2113). */
93
+ const RESERVED_QUERY_KEYS = new Set(['page', 'search'])
94
+ const warnedReserved = new Set<string>()
95
+
96
+ /**
97
+ * A filter named `page` or `search` silently overwrites the query key of
98
+ * the same name, and is overwritten back on restore. `search` has behaved
99
+ * this way since the URL sync existed and nobody ever reported it, which
100
+ * says how quiet the failure is; `page` joins the reserved set now that the
101
+ * page number lives in the URL too.
102
+ *
103
+ * We name what happens INSTEAD of what was asked, rather than dropping the
104
+ * filter — renaming someone's filter behind their back would be a worse
105
+ * surprise than the collision (ADR 0011).
106
+ */
107
+ function warnIfReservedName(name: string): void {
108
+ if (!RESERVED_QUERY_KEYS.has(name)) return
109
+ if (!syncUrlParams) return
110
+ if (warnedReserved.has(name)) return
111
+ warnedReserved.add(name)
112
+ console.warn(
113
+ `[qdadm] Filter "${name}" on "${entityName}" collides with the URL ` +
114
+ `parameter of the same name. With syncUrlParams on, the list's own ` +
115
+ `${name === 'page' ? 'page number' : 'search query'} wins and this ` +
116
+ `filter will not survive a reload. Rename the filter, or set ` +
117
+ `syncUrlParams: false on this list.`
118
+ )
119
+ }
120
+
92
121
  function addFilter(name: string, filterConfig: Omit<FilterConfig, 'name'>): void {
122
+ warnIfReservedName(name)
93
123
  filtersMap.value.set(name, {
94
124
  name,
95
125
  type: 'select',
@@ -115,6 +145,46 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
115
145
  onFiltersChanged()
116
146
  }
117
147
 
148
+ /**
149
+ * Push the list's own state into the URL (#2113).
150
+ *
151
+ * Filters, search AND the page number, each written only when it differs
152
+ * from the default and removed otherwise, so a pristine list leaves a clean
153
+ * URL. `router.replace` rather than `push`: paging is not a history step of
154
+ * its own, but the URL must carry the page at the moment the user leaves
155
+ * for a detail view, so that coming back restores it.
156
+ */
157
+ function writeStateToUrl(): void {
158
+ if (!syncUrlParams) return
159
+
160
+ const query = { ...route.query } as Record<string, string>
161
+
162
+ for (const [name, value] of Object.entries(filterValues.value)) {
163
+ if (value !== null && value !== undefined && value !== '') {
164
+ query[name] = String(value)
165
+ } else {
166
+ delete query[name]
167
+ }
168
+ }
169
+
170
+ if (searchQuery.value) {
171
+ query.search = searchQuery.value
172
+ } else {
173
+ delete query.search
174
+ }
175
+
176
+ // Page 1 is the default and stays implicit. Reserved keys are written
177
+ // last so the list's own state wins a collision — the warning in
178
+ // addFilter() names that consequence at declaration time.
179
+ if (page.value > 1) {
180
+ query.page = String(page.value)
181
+ } else {
182
+ delete query.page
183
+ }
184
+
185
+ router.replace({ query })
186
+ }
187
+
118
188
  function onFiltersChanged(): void {
119
189
  page.value = 1
120
190
  loadItems()
@@ -136,22 +206,7 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
136
206
  }
137
207
  setSessionFilters(filterSessionKey, toPersist)
138
208
  }
139
- if (syncUrlParams) {
140
- const query = { ...route.query } as Record<string, string>
141
- for (const [name, value] of Object.entries(filterValues.value)) {
142
- if (value !== null && value !== undefined && value !== '') {
143
- query[name] = String(value)
144
- } else {
145
- delete query[name]
146
- }
147
- }
148
- if (searchQuery.value) {
149
- query.search = searchQuery.value
150
- } else {
151
- delete query.search
152
- }
153
- router.replace({ query })
154
- }
209
+ writeStateToUrl()
155
210
  }
156
211
 
157
212
  function clearFilters(): void {
@@ -164,15 +219,16 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
164
219
  if (persistFilters) {
165
220
  clearSessionFilters(filterSessionKey)
166
221
  }
222
+ page.value = 1
167
223
  if (syncUrlParams) {
168
224
  const query = { ...route.query } as Record<string, string>
169
225
  for (const key of filtersMap.value.keys()) {
170
226
  delete query[key]
171
227
  }
172
228
  delete query.search
229
+ delete query.page
173
230
  router.replace({ query })
174
231
  }
175
- page.value = 1
176
232
  loadItems()
177
233
  }
178
234
 
@@ -221,12 +277,49 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
221
277
  }
222
278
  }
223
279
 
280
+ /**
281
+ * Fetch the remote options that populate filter dropdowns (#1934 lot 1).
282
+ *
283
+ * The loop used to be sequential: three filters with remote options cost
284
+ * three round trips in a queue, and the rows waited behind the sum. They
285
+ * are independent, so they now go together and cost the slowest.
286
+ *
287
+ * Deduplicated by `optionsEntity` on purpose. Sequential execution had one
288
+ * accidental virtue — two filters on the same entity meant one fetch and
289
+ * one cache hit — and firing them in parallel would turn that into two
290
+ * concurrent fetches. Trading latency for duplicate requests is not a win,
291
+ * so filters sharing an entity are still chained behind one another while
292
+ * different entities run side by side.
293
+ *
294
+ * This no longer invokes `filter:alter`: that hook establishes what the
295
+ * query will ask for, so it belongs with the other query-settling work in
296
+ * `onMounted`, not behind the network (#1934 lot 2).
297
+ */
224
298
  async function loadFilterOptions(): Promise<void> {
225
- // Process filters configured directly via addFilter() (smart filter modes)
226
- for (const [filterName, filterDef] of filtersMap.value) {
227
- if (filterDef.options && filterDef.options.length > 1) continue
228
- if (filterDef.optionsFromCache) continue
299
+ const pending = Array.from(filtersMap.value).filter(([, filterDef]) => {
300
+ if (filterDef.options && filterDef.options.length > 1) return false
301
+ if (filterDef.optionsFromCache) return false
302
+ return true
303
+ })
304
+
305
+ // One chain per source entity; everything else is a chain of its own.
306
+ const chains = new Map<string, Promise<void>>()
229
307
 
308
+ await Promise.all(
309
+ pending.map(([filterName, filterDef]) => {
310
+ const key = filterDef.optionsEntity ? `entity:${filterDef.optionsEntity}` : `own:${filterName}`
311
+ const previous = chains.get(key) ?? Promise.resolve()
312
+ const next = previous.then(() => loadOneFilterOptions(filterName, filterDef))
313
+ chains.set(key, next)
314
+ return next
315
+ })
316
+ )
317
+
318
+ // Trigger Vue reactivity
319
+ filtersMap.value = new Map(filtersMap.value)
320
+ }
321
+
322
+ async function loadOneFilterOptions(filterName: string, filterDef: FilterConfig): Promise<void> {
230
323
  try {
231
324
  let rawOptions: Array<{ label: string; value: unknown }> | null = null
232
325
 
@@ -299,13 +392,6 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
299
392
  } catch (error) {
300
393
  console.warn(`[qdadm] Failed to load options for filter "${filterName}":`, error)
301
394
  }
302
- }
303
-
304
- // Invoke filter:alter hooks after all options are loaded
305
- await invokeFilterAlterHook()
306
-
307
- // Trigger Vue reactivity
308
- filtersMap.value = new Map(filtersMap.value)
309
395
  }
310
396
 
311
397
  async function updateCacheBasedFilters(): Promise<void> {
@@ -399,6 +485,13 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
399
485
  if (route.query.search) {
400
486
  searchQuery.value = route.query.search as string
401
487
  }
488
+ // The page must be restored HERE, before the first loadItems(): restoring
489
+ // it later would mean a first request for page 1 and a second for the
490
+ // real one (#2113).
491
+ const restoredPage = Number(route.query.page)
492
+ if (Number.isInteger(restoredPage) && restoredPage > 0) {
493
+ page.value = restoredPage
494
+ }
402
495
  }
403
496
 
404
497
  return {
@@ -417,5 +510,6 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
417
510
  loadFilterOptions,
418
511
  updateCacheBasedFilters,
419
512
  restoreFilters,
513
+ writeStateToUrl,
420
514
  }
421
515
  }
@@ -586,8 +586,8 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
586
586
  const cards = computed(() => Array.from(cardsMap.value.values()))
587
587
 
588
588
  // ============ FILTERS (#1195 — extracted subsystem) ============
589
- // invokeFilterAlterHook is a thunk: alterHooks is declared after the
590
- // actions section (it needs the action registry) and only runs post-setup.
589
+ // loadItems and setSearch are passed as thunks: both are declared further
590
+ // down and only ever run post-setup.
591
591
  const listFilters = useListFilters({
592
592
  entityName,
593
593
  manager,
@@ -605,7 +605,6 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
605
605
  entityFilters,
606
606
  loadItems: () => loadItems(),
607
607
  setSearch: (searchCfg) => setSearch(searchCfg),
608
- invokeFilterAlterHook: () => alterHooks.invokeFilterAlterHook(),
609
608
  })
610
609
  const {
611
610
  filtersMap,
@@ -623,6 +622,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
623
622
  loadFilterOptions,
624
623
  updateCacheBasedFilters,
625
624
  restoreFilters,
625
+ writeStateToUrl,
626
626
  } = listFilters
627
627
 
628
628
  // ============ ACTIONS ============
@@ -799,6 +799,10 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
799
799
  page.value = event.page + 1
800
800
  pageSize.value = event.rows
801
801
  persistPageSize(event.rows)
802
+ // The page joins the URL (#2113) — it was the only piece of list state
803
+ // that survived nothing, so leaving a list for a detail view and coming
804
+ // back dropped the user on page 1.
805
+ writeStateToUrl()
802
806
  loadItems()
803
807
  }
804
808
 
@@ -1018,7 +1022,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1018
1022
  headerActionsMap,
1019
1023
  actionRegistry,
1020
1024
  })
1021
- const { invokeListAlterHook } = alterHooks
1025
+ const { invokeListAlterHook, invokeFilterAlterHook } = alterHooks
1022
1026
 
1023
1027
  // ============ LIFECYCLE ============
1024
1028
  initFromRegistry()
@@ -1030,18 +1034,35 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1030
1034
  onMounted(async () => {
1031
1035
  window.addEventListener('resize', handleResize)
1032
1036
 
1037
+ // Two kinds of work, and they used to be interleaved (#1934).
1038
+ //
1039
+ // SETTLING THE QUERY comes first, and only this blocks the rows.
1040
+ // `restoreFilters()` reads the filters, the search and the page out of
1041
+ // the URL; the alter hooks may add filters and their default values, and
1042
+ // may replace a filter's `toQuery`/`local_filter` — all of which
1043
+ // `loadItems()` reads. Skipping ahead of them sends the wrong request,
1044
+ // and nothing retries it.
1045
+ //
1046
+ // FETCHING comes second. Filter options populate dropdowns and have no
1047
+ // bearing on what the rows request, so the rows no longer wait behind
1048
+ // them: a page whose filters pull options from related entities used to
1049
+ // show nothing until every one of those round trips had returned.
1033
1050
  restoreFilters()
1034
1051
 
1035
- if (!filterOptionsLoaded) {
1036
- filterOptionsLoaded = true
1037
- await loadFilterOptions()
1038
- }
1039
-
1052
+ await invokeFilterAlterHook()
1040
1053
  await invokeListAlterHook()
1041
1054
 
1042
1055
  if (loadOnMount && manager) {
1043
1056
  loadItems()
1044
1057
  }
1058
+
1059
+ if (!filterOptionsLoaded) {
1060
+ filterOptionsLoaded = true
1061
+ // Deliberately not awaited: the dropdowns fill in when they fill in.
1062
+ void loadFilterOptions().catch((error) => {
1063
+ console.warn('[qdadm] Failed to load filter options:', error)
1064
+ })
1065
+ }
1045
1066
  })
1046
1067
 
1047
1068
  onUnmounted(() => {
@@ -36,6 +36,13 @@ export interface ApiStorageOptions<T extends EntityRecord = EntityRecord> {
36
36
  getClient?: (() => HttpClient) | null
37
37
  responseItemsKey?: string
38
38
  responseTotalKey?: string
39
+ /**
40
+ * Response HEADER carrying the total, for APIs that report it there rather
41
+ * than in the body (`X-Total-Count` is the json-server convention). Read
42
+ * before `responseTotalKey`. The header must be CORS-exposed to be
43
+ * readable from a browser.
44
+ */
45
+ responseTotalHeader?: string | null
39
46
  paramMapping?: Record<string, string>
40
47
  normalize?: ((data: T, context?: RoutingContext | null) => T) | null
41
48
  denormalize?: ((data: Partial<T>) => Partial<T>) | null
@@ -60,6 +67,7 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
60
67
  readonly endpoint: string
61
68
  readonly responseItemsKey: string
62
69
  readonly responseTotalKey: string
70
+ readonly responseTotalHeader: string | null
63
71
  readonly paramMapping: Record<string, string>
64
72
 
65
73
  protected _client: HttpClient | null
@@ -75,6 +83,7 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
75
83
  getClient = null,
76
84
  responseItemsKey = 'items',
77
85
  responseTotalKey = 'total',
86
+ responseTotalHeader = null,
78
87
  paramMapping = {},
79
88
  normalize = null,
80
89
  denormalize = null,
@@ -85,13 +94,16 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
85
94
  this._getClient = getClient
86
95
  this.responseItemsKey = responseItemsKey
87
96
  this.responseTotalKey = responseTotalKey
97
+ this.responseTotalHeader = responseTotalHeader
88
98
  this.paramMapping = paramMapping
89
99
  this._normalize = normalize
90
100
  this._denormalize = denormalize
91
101
  }
92
102
 
93
103
  /**
94
- * Apply parameter mapping to transform filter names
104
+ * Rename outgoing query parameters to the backend's vocabulary.
105
+ *
106
+ * Applies to filters AND to the pagination/sort keys (#2113) — see `list()`.
95
107
  */
96
108
  protected _applyParamMapping(params: Record<string, unknown>): Record<string, unknown> {
97
109
  if (!this.paramMapping || Object.keys(this.paramMapping).length === 0) {
@@ -141,10 +153,19 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
141
153
  async list(params: ListParams = {}, context: RoutingContext | null = null): Promise<ListResult<T>> {
142
154
  const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
143
155
 
144
- const mappedFilters = this._applyParamMapping(filters)
156
+ // `paramMapping` covers the WHOLE outgoing query, pagination included
157
+ // (#2113). It used to be applied to the filters only, so `status` could
158
+ // be renamed to `state` but `page_size` could not be renamed to `limit` —
159
+ // and an API speaking any other pagination dialect had to subclass the
160
+ // storage for that alone. The names qdadm uses internally are its own
161
+ // business; what goes on the wire is the backend's.
162
+ //
163
+ // Filters keep their precedence over the pagination keys, exactly as
164
+ // before: a filter named `page` still wins, for better or worse.
165
+ const outgoing = { page, page_size, sort_by, sort_order, ...filters }
145
166
 
146
167
  const response = await this.client.get<Record<string, unknown>>(this.endpoint, {
147
- params: { page, page_size, sort_by, sort_order, ...mappedFilters },
168
+ params: this._applyParamMapping(outgoing),
148
169
  })
149
170
 
150
171
  const data = response.data
@@ -154,12 +175,39 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
154
175
  return {
155
176
  items,
156
177
  total:
157
- (data[this.responseTotalKey] as number) ||
158
- (data.total as number) ||
159
- (Array.isArray(data) ? data.length : 0),
178
+ this._totalFromHeader(response) ??
179
+ ((data[this.responseTotalKey] as number) ||
180
+ (data.total as number) ||
181
+ (Array.isArray(data) ? data.length : 0)),
160
182
  }
161
183
  }
162
184
 
185
+ /**
186
+ * Total announced in a response header, when `responseTotalHeader` says so.
187
+ *
188
+ * Returns null — not 0 — when there is nothing to read, so the body-based
189
+ * fallbacks still get their turn. A 0 from the server is a real answer and
190
+ * is preserved.
191
+ */
192
+ protected _totalFromHeader(response: unknown): number | null {
193
+ if (!this.responseTotalHeader) return null
194
+
195
+ const headers = (response as { headers?: unknown })?.headers as
196
+ | { get?: (name: string) => unknown }
197
+ | Record<string, unknown>
198
+ | undefined
199
+ if (!headers) return null
200
+
201
+ const raw =
202
+ typeof (headers as { get?: unknown }).get === 'function'
203
+ ? (headers as { get: (name: string) => unknown }).get(this.responseTotalHeader)
204
+ : (headers as Record<string, unknown>)[this.responseTotalHeader.toLowerCase()]
205
+
206
+ if (raw === null || raw === undefined || raw === '') return null
207
+ const total = Number(raw)
208
+ return Number.isFinite(total) ? total : null
209
+ }
210
+
163
211
  async get(id: string | number, context: RoutingContext | null = null): Promise<T> {
164
212
  const response = await this.client.get<T>(`${this.endpoint}/${id}`)
165
213
  return this._normalizeData(response.data, context) as T
@@ -76,6 +76,7 @@ import { applyRoutingMethods } from './Kernel.routing'
76
76
  import { applyModuleMethods } from './Kernel.modules'
77
77
  import { applyRegistryMethods } from './Kernel.registries'
78
78
  import { setQdadmDebugBar, applyVueMethods } from './Kernel.vue'
79
+ import { isDebugBarKilled } from './debugBarKillSwitch'
79
80
  import { applyApiMethods } from './Kernel.api'
80
81
  import { applyI18nMethods } from './Kernel.i18n'
81
82
  import type { I18n } from '../i18n/I18n'
@@ -144,10 +145,29 @@ export class Kernel {
144
145
  const debugModule = new DebugModuleClass(debugModuleOptions)
145
146
  options.moduleDefs = options.moduleDefs || []
146
147
  options.moduleDefs.push(debugModule)
148
+
149
+ // `enabled: false` now actually disables (#1900 lot D).
150
+ //
151
+ // The component used to be registered whatever `enabled` said, and
152
+ // Kernel forced options.debug = true on top — which DebugModule.enabled()
153
+ // reads as "turn on". A consumer who wrote `debugBar: { enabled: false }`
154
+ // to switch the bar OFF found it running in PRODUCTION, and the only way
155
+ // out was to omit the key entirely.
156
+ //
157
+ // Note this cuts both ways: an app that passed `enabled: false` believing
158
+ // the key inert — including to keep the bar ON, "since it changes
159
+ // nothing" — will now see it disappear. Both readings were possible
160
+ // precisely because the flag did nothing.
161
+ // The emergency switch wins over everything (#1900 lot C.3): a bar that
162
+ // is taking the app down has to be stoppable without a redeploy, so
163
+ // `?qddebug=off` overrides even an explicit `enabled: true`.
164
+ const debugBarEnabled = debugModuleOptions.enabled !== false && !isDebugBarKilled()
165
+
147
166
  // Store component for root wrapper
148
- setQdadmDebugBar(options.debugBar.component || null)
149
- // Enable debug mode
150
- if (!options.debug) {
167
+ setQdadmDebugBar(debugBarEnabled ? options.debugBar.component || null : null)
168
+
169
+ // Debug mode follows the bar, and only when the bar is actually on.
170
+ if (debugBarEnabled && !options.debug) {
151
171
  options.debug = true
152
172
  }
153
173
  }
@@ -3,6 +3,7 @@ import {
3
3
  h,
4
4
  defineComponent,
5
5
  ref,
6
+ onErrorCaptured,
6
7
  type App,
7
8
  type Component,
8
9
  type Ref,
@@ -58,6 +59,51 @@ export function setQdadmDebugBar(component: Component | null): void {
58
59
  /**
59
60
  * Patch Kernel prototype with Vue app creation methods.
60
61
  */
62
+ /**
63
+ * The debug bar must not be able to take down the app it observes
64
+ * (#1900 lot C.1).
65
+ *
66
+ * Before this, a bar that threw during render propagated the error to the root
67
+ * and killed the application — a diagnostic tool failing precisely when
68
+ * someone needed it, and the consumer's only way out was to rebuild without
69
+ * it.
70
+ *
71
+ * `onErrorCaptured` returning false stops the error here: the bar is dropped,
72
+ * the app keeps rendering. The failure is loud in the console rather than on
73
+ * screen, because a crashed debug tool leaving debris in the corner of a
74
+ * working app helps nobody.
75
+ *
76
+ * This catches THROWN errors only. A runaway render loop throws nothing, so it
77
+ * needs its own guard — that is the bar's circuit breaker in `@quazardous/qddebug`,
78
+ * and the two are deliberately separate.
79
+ *
80
+ * Exported so the guard can be tested as a unit; the Kernel is its only caller.
81
+ */
82
+ export function createDebugBarBoundary(BarComponent: Component): Component {
83
+ return defineComponent({
84
+ name: 'QdadmDebugBarBoundary',
85
+ setup() {
86
+ const failed = ref(false)
87
+
88
+ onErrorCaptured((err: unknown) => {
89
+ if (!failed.value) {
90
+ failed.value = true
91
+ console.error(
92
+ '[qdadm] The debug bar threw and has been removed for this ' +
93
+ 'session. The application is unaffected. Use ?qddebug=off to ' +
94
+ 'keep it off across reloads.',
95
+ err
96
+ )
97
+ }
98
+ // Stop propagation: the app must not die with its instrument.
99
+ return false
100
+ })
101
+
102
+ return () => (failed.value ? null : h(BarComponent))
103
+ },
104
+ })
105
+ }
106
+
61
107
  export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
62
108
  const proto = KernelClass.prototype
63
109
 
@@ -102,6 +148,9 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
102
148
  this.options.debugBar?.component && debugBarRef.value
103
149
  ? debugBarRef.value
104
150
  : null
151
+ const DebugBarBoundary = DebugBarComponent
152
+ ? createDebugBarBoundary(DebugBarComponent as Component)
153
+ : null
105
154
  const hasPrimeVue = !!this.options.primevue?.plugin
106
155
  const appKey = this._appKey
107
156
 
@@ -116,8 +165,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
116
165
  children.push(h(ToastListener))
117
166
  }
118
167
 
119
- if (DebugBarComponent) {
120
- children.push(h(DebugBarComponent))
168
+ if (DebugBarBoundary) {
169
+ children.push(h(DebugBarBoundary))
121
170
  }
122
171
 
123
172
  return h(
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The emergency switch for the debug bar (#1900 lot C.3).
3
+ *
4
+ * A consumer whose debug bar took down the application it was observing had
5
+ * no way to turn it off: they had to rebuild and redeploy. A diagnostic tool
6
+ * that can only be disabled by a deploy fails exactly when it is needed.
7
+ *
8
+ * So the bar answers to a switch that needs no rebuild and no code change:
9
+ *
10
+ * ?qddebug=off turn it off, and REMEMBER that across navigations
11
+ * ?qddebug=on turn it back on (clears the memory)
12
+ *
13
+ * The query parameter is the way in — it is the only thing reachable when the
14
+ * app is unusable — and localStorage is what makes it stick, so the next page
15
+ * load is survivable too. Without persistence the switch would be lost by the
16
+ * first redirect, which is the moment a broken app is most likely to hit.
17
+ *
18
+ * This is a KILL switch, deliberately one-directional in effect: it can only
19
+ * take the bar away, never conjure one that `enabled: false` refused. See
20
+ * [[docs/adr/0011]] — the point of the switch is that it does something
21
+ * observable, and `?qddebug=on` on an app with no bar configured stays a
22
+ * no-op because there is nothing to turn on.
23
+ */
24
+
25
+ /** localStorage key, under qddebug's existing `qddebug:` namespace (ADR 0008). */
26
+ const KILL_KEY = 'qddebug:off'
27
+
28
+ /** Read a key without letting a hostile storage throw into the boot path. */
29
+ function readFlag(): boolean {
30
+ try {
31
+ return localStorage.getItem(KILL_KEY) === '1'
32
+ } catch {
33
+ // Private mode, disabled cookies, quota. Absent, not fatal.
34
+ return false
35
+ }
36
+ }
37
+
38
+ function writeFlag(on: boolean): void {
39
+ try {
40
+ if (on) localStorage.setItem(KILL_KEY, '1')
41
+ else localStorage.removeItem(KILL_KEY)
42
+ } catch {
43
+ // The switch still holds for THIS page load — the caller uses our return
44
+ // value, not the storage. It just will not survive a reload.
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Whether the debug bar has been killed for this browser.
50
+ *
51
+ * Reads the query parameter first (it is an explicit act, and it updates the
52
+ * remembered state), then falls back to what was remembered.
53
+ *
54
+ * Safe to call before anything is mounted, and outside a browser.
55
+ */
56
+ export function isDebugBarKilled(): boolean {
57
+ if (typeof window === 'undefined') return false
58
+
59
+ let param: string | null = null
60
+ try {
61
+ param = new URLSearchParams(window.location.search).get('qddebug')
62
+ } catch {
63
+ // Nothing readable in the URL; fall through to the remembered flag.
64
+ }
65
+
66
+ if (param === 'off') {
67
+ writeFlag(true)
68
+ return true
69
+ }
70
+ if (param === 'on') {
71
+ writeFlag(false)
72
+ return false
73
+ }
74
+
75
+ return readFlag()
76
+ }
77
+
78
+ /** Exported for tests and for an app that wants to offer its own switch. */
79
+ export const DEBUG_BAR_KILL_KEY = KILL_KEY