nuxt-devtools-observatory 0.1.11 → 0.1.13

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.
@@ -5,9 +5,21 @@ import { useObservatoryData, type InjectEntry, type ProvideEntry } from '../stor
5
5
  interface TreeNodeData {
6
6
  id: string
7
7
  label: string
8
+ componentName: string
8
9
  type: 'provider' | 'consumer' | 'both' | 'error'
9
- provides: Array<{ key: string; val: string; raw: unknown; reactive: boolean; complex: boolean }>
10
- injects: Array<{ key: string; from: string | null; ok: boolean }>
10
+ provides: Array<{
11
+ key: string
12
+ val: string
13
+ raw: unknown
14
+ reactive: boolean
15
+ complex: boolean
16
+ scope: 'global' | 'layout' | 'component'
17
+ isShadowing: boolean
18
+ /** UIDs of components that inject this key */
19
+ consumerUids: number[]
20
+ consumerNames: string[]
21
+ }>
22
+ injects: Array<{ key: string; from: string | null; fromName: string | null; ok: boolean }>
11
23
  children: TreeNodeData[]
12
24
  }
13
25
 
@@ -43,11 +55,38 @@ function nodeColor(node: TreeNodeData): string {
43
55
  function matchesFilter(node: TreeNodeData, filter: string): boolean {
44
56
  if (filter === 'all') return true
45
57
  if (filter === 'warn') return node.injects.some((entry) => !entry.ok)
58
+ if (filter === 'shadow') return node.provides.some((entry) => entry.isShadowing)
46
59
  return node.provides.some((entry) => entry.key === filter) || node.injects.some((entry) => entry.key === filter)
47
60
  }
48
61
 
49
- function countLeaves(node: TreeNodeData): number {
50
- return node.children.length === 0 ? 1 : node.children.reduce((sum, child) => sum + countLeaves(child), 0)
62
+ function matchesSearch(node: TreeNodeData, query: string): boolean {
63
+ if (!query) return true
64
+ const q = query.toLowerCase()
65
+ return (
66
+ node.label.toLowerCase().includes(q) ||
67
+ node.componentName.toLowerCase().includes(q) ||
68
+ node.provides.some((p) => p.key.toLowerCase().includes(q)) ||
69
+ node.injects.some((i) => i.key.toLowerCase().includes(q))
70
+ )
71
+ }
72
+
73
+ /**
74
+ * Count leaf nodes in a subtree iteratively to avoid stack overflow on
75
+ * pathologically deep provide/inject trees (e.g. every component re-providing
76
+ * the same key creates a chain as long as the component tree itself).
77
+ */
78
+ function countLeaves(root: TreeNodeData): number {
79
+ let count = 0
80
+ const stack: TreeNodeData[] = [root]
81
+ while (stack.length) {
82
+ const node = stack.pop()!
83
+ if (node.children.length === 0) {
84
+ count++
85
+ } else {
86
+ stack.push(...node.children)
87
+ }
88
+ }
89
+ return count
51
90
  }
52
91
 
53
92
  function stringifyValue(value: unknown) {
@@ -122,6 +161,7 @@ const nodes = computed<TreeNodeData[]>(() => {
122
161
  const created: TreeNodeData = {
123
162
  id,
124
163
  label: basename(entry.componentFile),
164
+ componentName: entry.componentName ?? basename(entry.componentFile),
125
165
  type: 'consumer',
126
166
  provides: [],
127
167
  injects: [],
@@ -133,14 +173,36 @@ const nodes = computed<TreeNodeData[]>(() => {
133
173
  return created
134
174
  }
135
175
 
176
+ // Build a lookup: key → list of inject entries (to compute consumer lists)
177
+ const injectsByKey = new Map<string, InjectEntry[]>()
178
+ for (const entry of provideInject.value.injects) {
179
+ const list = injectsByKey.get(entry.key) ?? []
180
+ list.push(entry)
181
+ injectsByKey.set(entry.key, list)
182
+ }
183
+
184
+ // Build a lookup: uid → componentName for resolvedFrom display
185
+ const nameByUid = new Map<number, string>()
186
+ for (const entry of provideInject.value.provides) {
187
+ nameByUid.set(entry.componentUid, entry.componentName)
188
+ }
189
+ for (const entry of provideInject.value.injects) {
190
+ nameByUid.set(entry.componentUid, entry.componentName)
191
+ }
192
+
136
193
  for (const entry of provideInject.value.provides) {
137
194
  const node = ensureNode(entry)
195
+ const consumers = injectsByKey.get(entry.key) ?? []
138
196
  node.provides.push({
139
197
  key: entry.key,
140
198
  val: formatValuePreview(entry.valueSnapshot),
141
199
  raw: entry.valueSnapshot,
142
200
  reactive: entry.isReactive,
143
201
  complex: isComplexValue(entry.valueSnapshot),
202
+ scope: entry.scope ?? 'component',
203
+ isShadowing: entry.isShadowing ?? false,
204
+ consumerUids: consumers.map((c) => c.componentUid),
205
+ consumerNames: consumers.map((c) => c.componentName),
144
206
  })
145
207
  }
146
208
 
@@ -149,6 +211,7 @@ const nodes = computed<TreeNodeData[]>(() => {
149
211
  node.injects.push({
150
212
  key: entry.key,
151
213
  from: entry.resolvedFromFile ?? null,
214
+ fromName: entry.resolvedFromUid !== undefined ? (nameByUid.get(entry.resolvedFromUid) ?? null) : null,
152
215
  ok: entry.resolved,
153
216
  })
154
217
  }
@@ -182,6 +245,7 @@ const nodes = computed<TreeNodeData[]>(() => {
182
245
  })
183
246
 
184
247
  const activeFilter = ref('all')
248
+ const searchQuery = ref('')
185
249
  const selectedNode = ref<TreeNodeData | null>(null)
186
250
  const expandedProvideValues = ref<Set<string>>(new Set())
187
251
 
@@ -203,36 +267,52 @@ function toggleProvideValue(id: string) {
203
267
 
204
268
  const allKeys = computed(() => {
205
269
  const keys = new Set<string>()
206
-
207
- function collect(nodesToVisit: TreeNodeData[]) {
208
- nodesToVisit.forEach((node) => {
209
- node.provides.forEach((entry) => keys.add(entry.key))
210
- node.injects.forEach((entry) => keys.add(entry.key))
211
- collect(node.children)
212
- })
270
+ const stack = [...nodes.value]
271
+ while (stack.length) {
272
+ const node = stack.pop()!
273
+ node.provides.forEach((entry) => keys.add(entry.key))
274
+ node.injects.forEach((entry) => keys.add(entry.key))
275
+ stack.push(...node.children)
213
276
  }
214
-
215
- collect(nodes.value)
216
-
217
277
  return [...keys]
218
278
  })
219
279
 
220
280
  const visibleNodes = computed<TreeNodeData[]>(() => {
221
- function prune(node: TreeNodeData): TreeNodeData | null {
222
- const visibleChildren = node.children.map(prune).filter(Boolean) as TreeNodeData[]
223
- const selfMatches = matchesFilter(node, activeFilter.value)
224
-
225
- if (!selfMatches && !visibleChildren.length) {
226
- return null
281
+ // Iterative post-order prune avoids stack overflow on deep trees.
282
+ // We process nodes bottom-up so each parent can inspect its children's
283
+ // already-computed visibility before deciding its own.
284
+ function pruneIterative(root: TreeNodeData): TreeNodeData | null {
285
+ // Phase 1: collect nodes in pre-order (parent before children)
286
+ const order: TreeNodeData[] = []
287
+ const stack: TreeNodeData[] = [root]
288
+ while (stack.length) {
289
+ const node = stack.pop()!
290
+ order.push(node)
291
+ for (let i = node.children.length - 1; i >= 0; i--) {
292
+ stack.push(node.children[i])
293
+ }
227
294
  }
228
295
 
229
- return {
230
- ...node,
231
- children: visibleChildren,
296
+ // Phase 2: process in reverse pre-order (children before parents)
297
+ const pruned = new Map<TreeNodeData, TreeNodeData | null>()
298
+ for (let i = order.length - 1; i >= 0; i--) {
299
+ const node = order[i]
300
+ const visibleChildren = node.children
301
+ .map((child) => pruned.get(child) ?? null)
302
+ .filter((child): child is TreeNodeData => child !== null)
303
+ const selfMatches = matchesFilter(node, activeFilter.value) && matchesSearch(node, searchQuery.value)
304
+
305
+ if (!selfMatches && !visibleChildren.length) {
306
+ pruned.set(node, null)
307
+ } else {
308
+ pruned.set(node, { ...node, children: visibleChildren })
309
+ }
232
310
  }
311
+
312
+ return pruned.get(root) ?? null
233
313
  }
234
314
 
235
- return nodes.value.map(prune).filter(Boolean) as TreeNodeData[]
315
+ return nodes.value.map(pruneIterative).filter(Boolean) as TreeNodeData[]
236
316
  })
237
317
 
238
318
  watch([visibleNodes, selectedNode], ([currentNodes, currentSelected]) => {
@@ -241,16 +321,13 @@ watch([visibleNodes, selectedNode], ([currentNodes, currentSelected]) => {
241
321
  }
242
322
 
243
323
  const ids = new Set<string>()
244
-
245
- function collect(nodesToVisit: TreeNodeData[]) {
246
- nodesToVisit.forEach((node) => {
247
- ids.add(node.id)
248
- collect(node.children)
249
- })
324
+ const stack = [...currentNodes]
325
+ while (stack.length) {
326
+ const node = stack.pop()!
327
+ ids.add(node.id)
328
+ stack.push(...node.children)
250
329
  }
251
330
 
252
- collect(currentNodes)
253
-
254
331
  if (!ids.has(currentSelected.id)) {
255
332
  selectedNode.value = null
256
333
  }
@@ -260,32 +337,47 @@ const layout = computed<LayoutNode[]>(() => {
260
337
  const flat: LayoutNode[] = []
261
338
  const pad = H_GAP
262
339
 
263
- function place(node: TreeNodeData, depth: number, slotLeft: number, parentId: string | null) {
264
- const leaves = countLeaves(node)
265
- const slotWidth = leaves * (NODE_W + H_GAP) - H_GAP
266
-
267
- flat.push({
268
- data: node,
269
- parentId,
270
- x: Math.round(slotLeft + slotWidth / 2),
271
- y: Math.round(pad + depth * (NODE_H + V_GAP) + NODE_H / 2),
272
- })
273
-
274
- let childLeft = slotLeft
275
-
276
- for (const child of node.children) {
277
- const childLeaves = countLeaves(child)
278
- place(child, depth + 1, childLeft, node.id)
279
- childLeft += childLeaves * (NODE_W + H_GAP)
280
- }
340
+ // Iterative replacement for the recursive place() avoids stack overflow
341
+ // on deep component trees. Uses an explicit stack of pending work items.
342
+ interface WorkItem {
343
+ node: TreeNodeData
344
+ depth: number
345
+ slotLeft: number
346
+ parentId: string | null
281
347
  }
282
348
 
283
349
  let left = pad
284
350
 
285
351
  for (const root of visibleNodes.value) {
286
- const leaves = countLeaves(root)
287
- place(root, 0, left, null)
288
- left += leaves * (NODE_W + H_GAP) + H_GAP * 2
352
+ const stack: WorkItem[] = [{ node: root, depth: 0, slotLeft: left, parentId: null }]
353
+
354
+ while (stack.length) {
355
+ const { node, depth, slotLeft, parentId } = stack.pop()!
356
+ const leaves = countLeaves(node)
357
+ const slotWidth = leaves * (NODE_W + H_GAP) - H_GAP
358
+
359
+ flat.push({
360
+ data: node,
361
+ parentId,
362
+ x: Math.round(slotLeft + slotWidth / 2),
363
+ y: Math.round(pad + depth * (NODE_H + V_GAP) + NODE_H / 2),
364
+ })
365
+
366
+ // Push children in reverse so leftmost child is processed first
367
+ let childLeft = slotLeft
368
+ const childWork: WorkItem[] = []
369
+ for (const child of node.children) {
370
+ const childLeaves = countLeaves(child)
371
+ childWork.push({ node: child, depth: depth + 1, slotLeft: childLeft, parentId: node.id })
372
+ childLeft += childLeaves * (NODE_W + H_GAP)
373
+ }
374
+ for (let i = childWork.length - 1; i >= 0; i--) {
375
+ stack.push(childWork[i])
376
+ }
377
+ }
378
+
379
+ const rootLeaves = countLeaves(root)
380
+ left += rootLeaves * (NODE_W + H_GAP) + H_GAP * 2
289
381
  }
290
382
 
291
383
  return flat
@@ -325,9 +417,17 @@ const edges = computed<Edge[]>(() => {
325
417
  >
326
418
  {{ key }}
327
419
  </button>
328
- <button style="margin-left: auto" :class="{ 'danger-active': activeFilter === 'warn' }" @click="activeFilter = 'warn'">
329
- warnings only
420
+ <button
421
+ style="margin-left: auto"
422
+ :class="{ 'danger-active': activeFilter === 'shadow' }"
423
+ @click="activeFilter = activeFilter === 'shadow' ? 'all' : 'shadow'"
424
+ >
425
+ shadowed
426
+ </button>
427
+ <button :class="{ 'danger-active': activeFilter === 'warn' }" @click="activeFilter = activeFilter === 'warn' ? 'all' : 'warn'">
428
+ warnings
330
429
  </button>
430
+ <input v-model="searchQuery" type="search" placeholder="search component or key…" style="max-width: 200px" />
331
431
  </div>
332
432
 
333
433
  <div class="split">
@@ -368,7 +468,9 @@ const edges = computed<Edge[]>(() => {
368
468
  >
369
469
  <span class="node-dot" :style="{ background: nodeColor(layoutNode.data) }"></span>
370
470
  <span class="mono node-label">{{ layoutNode.data.label }}</span>
371
- <span v-if="layoutNode.data.provides.length" class="badge badge-ok badge-xs">+{{ layoutNode.data.provides.length }}</span>
471
+ <span v-if="layoutNode.data.provides.length" class="badge badge-ok badge-xs">
472
+ +{{ layoutNode.data.provides.length }}
473
+ </span>
372
474
  <span v-if="layoutNode.data.injects.some((entry) => !entry.ok)" class="badge badge-err badge-xs">!</span>
373
475
  </div>
374
476
  </div>
@@ -395,6 +497,7 @@ const edges = computed<Edge[]>(() => {
395
497
  <div class="row-main">
396
498
  <span class="mono text-sm row-key">{{ entry.key }}</span>
397
499
  <span class="mono text-sm muted row-value-preview" :title="entry.val">{{ entry.val }}</span>
500
+ <span class="badge scope-badge" :class="`scope-${entry.scope}`">{{ entry.scope }}</span>
398
501
  <span class="badge" :class="entry.reactive ? 'badge-ok' : 'badge-gray'">
399
502
  {{ entry.reactive ? 'reactive' : 'static' }}
400
503
  </span>
@@ -406,22 +509,43 @@ const edges = computed<Edge[]>(() => {
406
509
  {{ expandedProvideValues.has(provideValueId(selectedNode.id, entry.key, index)) ? 'hide' : 'view' }}
407
510
  </button>
408
511
  </div>
512
+ <!-- Shadowing warning -->
513
+ <div v-if="entry.isShadowing" class="row-warning">shadows a parent provide with the same key</div>
514
+ <!-- Consumer list -->
515
+ <div v-if="entry.consumerNames.length" class="row-consumers">
516
+ <span class="muted text-sm">used by:</span>
517
+ <span v-for="name in entry.consumerNames" :key="name" class="consumer-chip mono">{{ name }}</span>
518
+ <span v-if="!entry.consumerNames.length" class="muted text-sm">no consumers</span>
519
+ </div>
520
+ <div v-else class="muted text-sm" style="padding: 2px 0; font-size: 11px">no consumers detected</div>
409
521
  <pre
410
522
  v-if="entry.complex && expandedProvideValues.has(provideValueId(selectedNode.id, entry.key, index))"
411
523
  class="value-box"
412
- >{{ formatValueDetail(entry.raw) }}</pre>
524
+ >{{ formatValueDetail(entry.raw) }}</pre
525
+ >
413
526
  </div>
414
527
  </div>
415
528
  </div>
416
529
 
417
- <div v-if="selectedNode.injects.length" class="detail-section" :style="{ marginTop: selectedNode.provides.length ? '10px' : '0' }">
530
+ <div
531
+ v-if="selectedNode.injects.length"
532
+ class="detail-section"
533
+ :style="{ marginTop: selectedNode.provides.length ? '10px' : '0' }"
534
+ >
418
535
  <div class="section-label">injects ({{ selectedNode.injects.length }})</div>
419
536
  <div class="detail-list">
420
- <div v-for="entry in selectedNode.injects" :key="entry.key" class="inject-row" :class="{ 'inject-miss': !entry.ok }">
537
+ <div
538
+ v-for="entry in selectedNode.injects"
539
+ :key="entry.key"
540
+ class="inject-row"
541
+ :class="{ 'inject-miss': !entry.ok }"
542
+ >
421
543
  <span class="mono text-sm row-key">{{ entry.key }}</span>
422
544
  <span v-if="entry.ok" class="badge badge-ok">resolved</span>
423
545
  <span v-else class="badge badge-err">no provider</span>
424
- <span class="mono muted text-sm row-from" :title="entry.from ?? 'undefined'">{{ entry.from ?? 'undefined' }}</span>
546
+ <span class="mono text-sm row-from" :class="entry.fromName ? '' : 'muted'" :title="entry.from ?? 'undefined'">
547
+ {{ entry.fromName ?? entry.from ?? 'undefined' }}
548
+ </span>
425
549
  </div>
426
550
  </div>
427
551
  </div>
@@ -628,13 +752,60 @@ const edges = computed<Edge[]>(() => {
628
752
  .provide-row {
629
753
  display: flex;
630
754
  flex-direction: column;
631
- gap: 6px;
755
+ gap: 4px;
632
756
  padding: 5px 8px;
633
757
  background: var(--bg2);
634
758
  border-radius: var(--radius);
635
759
  margin-bottom: 3px;
636
760
  }
637
761
 
762
+ .row-warning {
763
+ font-size: 11px;
764
+ color: var(--amber);
765
+ padding: 2px 0;
766
+ }
767
+
768
+ .row-consumers {
769
+ display: flex;
770
+ flex-wrap: wrap;
771
+ align-items: center;
772
+ gap: 4px;
773
+ padding: 2px 0;
774
+ }
775
+
776
+ .consumer-chip {
777
+ font-size: 10px;
778
+ padding: 1px 6px;
779
+ border-radius: 4px;
780
+ background: color-mix(in srgb, var(--blue) 10%, var(--bg3));
781
+ border: 0.5px solid color-mix(in srgb, var(--blue) 30%, var(--border));
782
+ color: var(--text2);
783
+ }
784
+
785
+ .scope-badge {
786
+ font-size: 10px;
787
+ padding: 1px 6px;
788
+ border-radius: 4px;
789
+ }
790
+
791
+ .scope-global {
792
+ background: color-mix(in srgb, var(--amber) 15%, transparent);
793
+ border: 0.5px solid color-mix(in srgb, var(--amber) 40%, var(--border));
794
+ color: color-mix(in srgb, var(--amber) 80%, var(--text));
795
+ }
796
+
797
+ .scope-layout {
798
+ background: color-mix(in srgb, var(--purple) 15%, transparent);
799
+ border: 0.5px solid color-mix(in srgb, var(--purple) 40%, var(--border));
800
+ color: color-mix(in srgb, var(--purple) 80%, var(--text));
801
+ }
802
+
803
+ .scope-component {
804
+ background: var(--bg3);
805
+ border: 0.5px solid var(--border);
806
+ color: var(--text3);
807
+ }
808
+
638
809
  .row-main {
639
810
  display: flex;
640
811
  align-items: center;