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.
@@ -9,13 +9,15 @@ interface ComponentNode {
9
9
  element?: string
10
10
  depth: number
11
11
  path: string[]
12
- renders: number
13
- navigationRenders: number
12
+ rerenders: number
13
+ mountCount: number
14
14
  avgMs: number
15
15
  triggers: string[]
16
16
  children: ComponentNode[]
17
17
  parentId?: string
18
18
  parentLabel?: string
19
+ isPersistent: boolean
20
+ isHydrationMount: boolean
19
21
  }
20
22
 
21
23
  const TreeNode = defineComponent({
@@ -30,7 +32,7 @@ const TreeNode = defineComponent({
30
32
  emits: ['select', 'toggle'],
31
33
  setup(props, { emit }): () => VNode | null {
32
34
  function nodeValue(node: ComponentNode) {
33
- return props.mode === 'count' ? node.renders : node.avgMs
35
+ return props.mode === 'count' ? node.rerenders + node.mountCount : node.avgMs
34
36
  }
35
37
 
36
38
  function isHot(node: ComponentNode) {
@@ -48,7 +50,7 @@ const TreeNode = defineComponent({
48
50
  const node = props.node!
49
51
  const expanded = props.expandedIds?.has(node.id) ?? false
50
52
  const canExpand = node.children.length > 0
51
- const metric = props.mode === 'count' ? `${node.renders}` : `${node.avgMs.toFixed(1)}ms`
53
+ const metric = props.mode === 'count' ? `${node.rerenders + node.mountCount}` : `${node.avgMs.toFixed(1)}ms`
52
54
  const metricLabel = props.mode === 'count' ? 'renders' : 'avg'
53
55
  const badges = []
54
56
  const normalizedElement = node.element?.toLowerCase()
@@ -58,7 +60,11 @@ const TreeNode = defineComponent({
58
60
  }
59
61
 
60
62
  if (node.file !== 'unknown') {
61
- const fileBadge = node.file.split('/').pop()?.replace(/\.vue$/i, '') ?? node.file
63
+ const fileBadge =
64
+ node.file
65
+ .split('/')
66
+ .pop()
67
+ ?.replace(/\.vue$/i, '') ?? node.file
62
68
 
63
69
  if (fileBadge !== node.label && !badges.includes(fileBadge)) {
64
70
  badges.push(fileBadge)
@@ -104,7 +110,23 @@ const TreeNode = defineComponent({
104
110
  : null,
105
111
  ]),
106
112
  h('div', { class: 'tree-metrics mono' }, [
107
- node.navigationRenders ? h('span', { class: 'tree-nav-pill' }, `${node.navigationRenders} nav`) : null,
113
+ node.isPersistent
114
+ ? h(
115
+ 'span',
116
+ { class: 'tree-persistent-pill', title: 'Layout / persistent component — survives navigation' },
117
+ 'persistent'
118
+ )
119
+ : null,
120
+ node.isHydrationMount
121
+ ? h(
122
+ 'span',
123
+ {
124
+ class: 'tree-hydration-pill',
125
+ title: 'First mount was SSR hydration — not a user-triggered render',
126
+ },
127
+ 'hydrated'
128
+ )
129
+ : null,
108
130
  h('span', { class: 'tree-metric-pill' }, `${metric} ${metricLabel}`),
109
131
  ]),
110
132
  ]
@@ -134,7 +156,19 @@ const TreeNode = defineComponent({
134
156
  const { renders, connected } = useObservatoryData()
135
157
 
136
158
  const activeMode = ref<'count' | 'time'>('count')
137
- const activeThreshold = ref(5)
159
+ // Separate thresholds per mode so switching modes doesn't produce nonsense results.
160
+ // Count: flag components that rendered 3+ times (1 hydration mount is normal).
161
+ // Time: flag components averaging 16ms+ (one animation frame budget).
162
+ const countThreshold = ref(3)
163
+ const timeThreshold = ref(16)
164
+ // Writable computed so the threshold slider can use v-model directly.
165
+ const activeThreshold = computed({
166
+ get: () => (activeMode.value === 'count' ? countThreshold.value : timeThreshold.value),
167
+ set: (val: number) => {
168
+ if (activeMode.value === 'count') countThreshold.value = val
169
+ else timeThreshold.value = val
170
+ },
171
+ })
138
172
  const activeHotOnly = ref(false)
139
173
  const frozen = ref(false)
140
174
  const search = ref('')
@@ -153,7 +187,10 @@ function displayLabel(entry: RenderEntry) {
153
187
  return entry.element
154
188
  }
155
189
 
156
- const basename = entry.file.split('/').pop()?.replace(/\.vue$/i, '')
190
+ const basename = entry.file
191
+ .split('/')
192
+ .pop()
193
+ ?.replace(/\.vue$/i, '')
157
194
 
158
195
  if (basename && basename !== 'unknown') {
159
196
  return basename
@@ -181,12 +218,14 @@ function buildNodes(entries: RenderEntry[]) {
181
218
  element: entry.element,
182
219
  depth: 0,
183
220
  path: [],
184
- renders: entry.renders,
185
- navigationRenders: Number.isFinite(entry.navigationRenders) ? entry.navigationRenders : 0,
221
+ rerenders: entry.rerenders ?? 0,
222
+ mountCount: entry.mountCount ?? 1,
186
223
  avgMs: entry.avgMs,
187
224
  triggers: entry.triggers.map(formatTrigger),
188
225
  children: [],
189
226
  parentId: entry.parentUid !== undefined ? String(entry.parentUid) : undefined,
227
+ isPersistent: Boolean(entry.isPersistent),
228
+ isHydrationMount: Boolean(entry.isHydrationMount),
190
229
  })
191
230
  }
192
231
 
@@ -282,7 +321,17 @@ function defaultExpandedIds(root: ComponentNode | null) {
282
321
  return new Set<string>()
283
322
  }
284
323
 
285
- return new Set([root.id])
324
+ // Expand all nodes that have children — gives a fully-open tree on first load.
325
+ // The user can collapse individual branches as needed.
326
+ const expanded = new Set<string>()
327
+ function expandAll(node: ComponentNode) {
328
+ if (node.children.length > 0) {
329
+ expanded.add(node.id)
330
+ node.children.forEach(expandAll)
331
+ }
332
+ }
333
+ expandAll(root)
334
+ return expanded
286
335
  }
287
336
 
288
337
  function searchExpandedIds(root: ComponentNode | null, term: string) {
@@ -309,7 +358,7 @@ function searchExpandedIds(root: ComponentNode | null, term: string) {
309
358
  }
310
359
 
311
360
  function nodeValue(node: ComponentNode) {
312
- return activeMode.value === 'count' ? node.renders : node.avgMs
361
+ return activeMode.value === 'count' ? node.rerenders + node.mountCount : node.avgMs
313
362
  }
314
363
 
315
364
  function isHot(node: ComponentNode) {
@@ -411,8 +460,7 @@ const appEntries = computed(() =>
411
460
  )
412
461
 
413
462
  const activeSelected = computed(() => allComponents.value.find((node) => node.id === activeSelectedId.value) ?? null)
414
- const totalRenders = computed(() => allComponents.value.reduce((sum, node) => sum + node.renders, 0))
415
- const totalNavigationRenders = computed(() => allComponents.value.reduce((sum, node) => sum + (Number.isFinite(node.navigationRenders) ? node.navigationRenders : 0), 0))
463
+ const totalRenders = computed(() => allComponents.value.reduce((sum, node) => sum + node.rerenders + node.mountCount, 0))
416
464
  const hotCount = computed(() => allComponents.value.filter((node) => isHot(node)).length)
417
465
  const avgTime = computed(() => {
418
466
  const components = allComponents.value.filter((node) => node.avgMs > 0)
@@ -561,7 +609,12 @@ function toggleFreeze() {
561
609
  }
562
610
 
563
611
  function basename(file: string) {
564
- return file.split('/').pop()?.replace(/\.vue$/i, '') ?? file
612
+ return (
613
+ file
614
+ .split('/')
615
+ .pop()
616
+ ?.replace(/\.vue$/i, '') ?? file
617
+ )
565
618
  }
566
619
 
567
620
  function pathLabel(node: ComponentNode) {
@@ -578,8 +631,15 @@ function pathLabel(node: ComponentNode) {
578
631
  </div>
579
632
  <div class="threshold-group">
580
633
  <span class="muted text-sm">threshold</span>
581
- <input v-model.number="activeThreshold" type="range" min="1" max="30" step="1" style="width: 90px" />
582
- <span class="mono text-sm">{{ activeThreshold }}+</span>
634
+ <input
635
+ v-model.number="activeThreshold"
636
+ type="range"
637
+ :min="activeMode === 'count' ? 2 : 4"
638
+ :max="activeMode === 'count' ? 20 : 100"
639
+ :step="activeMode === 'count' ? 1 : 4"
640
+ style="width: 90px"
641
+ />
642
+ <span class="mono text-sm">{{ activeThreshold }}{{ activeMode === 'count' ? '+ renders' : 'ms+' }}</span>
583
643
  </div>
584
644
  <button :class="{ active: activeHotOnly }" @click="activeHotOnly = !activeHotOnly">hot only</button>
585
645
  <button :class="{ active: frozen }" style="margin-left: auto" @click="toggleFreeze">
@@ -595,7 +655,6 @@ function pathLabel(node: ComponentNode) {
595
655
  <div class="stat-card">
596
656
  <div class="stat-label">total renders</div>
597
657
  <div class="stat-val">{{ totalRenders }}</div>
598
- <div class="stat-sub mono">{{ totalNavigationRenders }} nav</div>
599
658
  </div>
600
659
  <div class="stat-card">
601
660
  <div class="stat-label">hot</div>
@@ -659,10 +718,23 @@ function pathLabel(node: ComponentNode) {
659
718
  </div>
660
719
 
661
720
  <div class="detail-pill-row">
662
- <span class="detail-pill mono">{{ activeSelected.renders }} renders</span>
663
- <span v-if="activeSelected.navigationRenders" class="detail-pill mono nav">{{ activeSelected.navigationRenders }} nav</span>
721
+ <span class="detail-pill mono">
722
+ {{ activeSelected.rerenders + activeSelected.mountCount }} render{{
723
+ activeSelected.rerenders + activeSelected.mountCount !== 1 ? 's' : ''
724
+ }}
725
+ </span>
726
+ <span class="detail-pill mono muted">
727
+ {{ activeSelected.mountCount }} mount{{ activeSelected.mountCount !== 1 ? 's' : '' }}
728
+ </span>
729
+ <span v-if="activeSelected.rerenders" class="detail-pill mono">
730
+ {{ activeSelected.rerenders }} re-render{{ activeSelected.rerenders !== 1 ? 's' : '' }}
731
+ </span>
732
+ <span v-if="activeSelected.isPersistent" class="detail-pill mono persistent">persistent</span>
733
+ <span v-if="activeSelected.isHydrationMount" class="detail-pill mono hydrated">hydrated</span>
664
734
  <span class="detail-pill mono">{{ activeSelected.avgMs.toFixed(1) }}ms avg</span>
665
- <span class="detail-pill mono" :class="{ hot: isHot(activeSelected) }">{{ isHot(activeSelected) ? 'hot' : 'cool' }}</span>
735
+ <span class="detail-pill mono" :class="{ hot: isHot(activeSelected) }">
736
+ {{ isHot(activeSelected) ? 'hot' : 'cool' }}
737
+ </span>
666
738
  </div>
667
739
 
668
740
  <div class="section-label">identity</div>
@@ -683,14 +755,24 @@ function pathLabel(node: ComponentNode) {
683
755
 
684
756
  <div class="section-label">rendering</div>
685
757
  <div class="meta-grid">
686
- <span class="muted text-sm">mode value</span>
687
- <span class="mono text-sm">{{ activeMode === 'count' ? activeSelected.renders : `${activeSelected.avgMs.toFixed(1)}ms` }}</span>
688
- <span class="muted text-sm">navigation renders</span>
689
- <span class="mono text-sm">{{ activeSelected.navigationRenders }}</span>
758
+ <span class="muted text-sm">total renders</span>
759
+ <span class="mono text-sm">{{ activeSelected.rerenders + activeSelected.mountCount }}</span>
760
+ <span class="muted text-sm">re-renders</span>
761
+ <span class="mono text-sm">{{ activeSelected.rerenders }}</span>
762
+ <span class="muted text-sm">mounts</span>
763
+ <span class="mono text-sm">{{ activeSelected.mountCount }}</span>
764
+ <span class="muted text-sm">persistent</span>
765
+ <span class="mono text-sm" :style="{ color: activeSelected.isPersistent ? 'var(--amber)' : 'inherit' }">
766
+ {{ activeSelected.isPersistent ? 'yes — survives navigation' : 'no' }}
767
+ </span>
768
+ <span class="muted text-sm">hydration mount</span>
769
+ <span class="mono text-sm">{{ activeSelected.isHydrationMount ? 'yes — SSR hydrated' : 'no' }}</span>
770
+ <span class="muted text-sm">avg render time</span>
771
+ <span class="mono text-sm">{{ activeSelected.avgMs.toFixed(1) }}ms</span>
690
772
  <span class="muted text-sm">threshold</span>
691
- <span class="mono text-sm">{{ activeThreshold }}</span>
692
- <span class="muted text-sm">selected mode</span>
693
- <span class="mono text-sm">{{ activeMode === 'count' ? 'render count' : 'render time' }}</span>
773
+ <span class="mono text-sm">{{ activeThreshold }}{{ activeMode === 'count' ? '+ renders' : 'ms+' }}</span>
774
+ <span class="muted text-sm">mode</span>
775
+ <span class="mono text-sm">{{ activeMode === 'count' ? 're-render count' : 'render time' }}</span>
694
776
  </div>
695
777
 
696
778
  <div class="section-label">triggers</div>
@@ -970,17 +1052,26 @@ function pathLabel(node: ComponentNode) {
970
1052
  color: var(--text3);
971
1053
  }
972
1054
 
973
- :deep(.tree-nav-pill) {
1055
+ :deep(.tree-persistent-pill) {
974
1056
  display: inline-flex;
975
1057
  align-items: center;
976
- justify-content: center;
977
- min-width: 54px;
978
1058
  padding: 2px 8px;
979
- border: 1px solid color-mix(in srgb, var(--purple) 55%, var(--border));
1059
+ border: 1px solid color-mix(in srgb, var(--amber) 55%, var(--border));
980
1060
  border-radius: 999px;
981
- background: color-mix(in srgb, var(--purple) 10%, var(--bg2));
1061
+ background: color-mix(in srgb, var(--amber) 10%, var(--bg2));
982
1062
  font-size: 10px;
983
- color: color-mix(in srgb, var(--purple) 70%, white);
1063
+ color: color-mix(in srgb, var(--amber) 80%, var(--text));
1064
+ }
1065
+
1066
+ :deep(.tree-hydration-pill) {
1067
+ display: inline-flex;
1068
+ align-items: center;
1069
+ padding: 2px 8px;
1070
+ border: 1px solid color-mix(in srgb, var(--teal) 55%, var(--border));
1071
+ border-radius: 999px;
1072
+ background: color-mix(in srgb, var(--teal) 10%, var(--bg2));
1073
+ font-size: 10px;
1074
+ color: color-mix(in srgb, var(--teal) 80%, var(--text));
984
1075
  }
985
1076
 
986
1077
  :deep(.tree-children) {
@@ -1029,9 +1120,19 @@ function pathLabel(node: ComponentNode) {
1029
1120
  color: var(--red);
1030
1121
  }
1031
1122
 
1032
- .detail-pill.nav {
1033
- border-color: color-mix(in srgb, var(--purple) 55%, var(--border));
1034
- color: color-mix(in srgb, var(--purple) 70%, white);
1123
+ .detail-pill.persistent {
1124
+ border-color: color-mix(in srgb, var(--amber) 55%, var(--border));
1125
+ color: color-mix(in srgb, var(--amber) 80%, var(--text));
1126
+ }
1127
+
1128
+ .detail-pill.hydrated {
1129
+ border-color: color-mix(in srgb, var(--teal) 55%, var(--border));
1130
+ color: color-mix(in srgb, var(--teal) 80%, var(--text));
1131
+ }
1132
+
1133
+ .detail-pill.muted {
1134
+ color: var(--text3);
1135
+ border-color: var(--border);
1035
1136
  }
1036
1137
 
1037
1138
  .section-label {
@@ -56,8 +56,8 @@ const timelineGeometry = computed(() => {
56
56
  return []
57
57
  }
58
58
 
59
- const minT = Math.min(...all.map((e) => e.startTime))
60
- const maxT = Math.max(...all.map((e) => e.endTime ?? e.startTime + 400))
59
+ const minT = all.reduce((min, e) => Math.min(min, e.startTime), all[0].startTime)
60
+ const maxT = all.reduce((max, e) => Math.max(max, e.endTime ?? e.startTime + 400), 0)
61
61
  const span = Math.max(maxT - minT, 1)
62
62
 
63
63
  return all.map((e) => ({
@@ -0,0 +1,124 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+
4
+ defineProps<{
5
+ value: unknown
6
+ compact?: boolean
7
+ }>()
8
+
9
+ const open = ref(false)
10
+
11
+ function truncate(s: string, max: number) {
12
+ return s.length > max ? s.slice(0, max) + '…' : s
13
+ }
14
+ </script>
15
+
16
+ <template>
17
+ <span class="vi">
18
+ <!-- null / undefined -->
19
+ <span v-if="value === null || value === undefined" class="vi-null">{{ String(value) }}</span>
20
+
21
+ <!-- boolean -->
22
+ <span v-else-if="typeof value === 'boolean'" class="vi-bool">{{ value }}</span>
23
+
24
+ <!-- number -->
25
+ <span v-else-if="typeof value === 'number'" class="vi-num">{{ value }}</span>
26
+
27
+ <!-- string -->
28
+ <span v-else-if="typeof value === 'string'" class="vi-str">"{{ compact ? truncate(value, 40) : value }}"</span>
29
+
30
+ <!-- array -->
31
+ <span v-else-if="Array.isArray(value)">
32
+ <span v-if="compact || !open">
33
+ <span class="vi-punc">[</span>
34
+ <span class="vi-dim">{{ value.length }} item{{ value.length !== 1 ? 's' : '' }}</span>
35
+ <span class="vi-punc">]</span>
36
+ <button v-if="!compact" class="vi-toggle" @click.stop="open = true">▸</button>
37
+ </span>
38
+ <span v-else class="vi-block">
39
+ <button class="vi-toggle" @click.stop="open = false">▾</button>
40
+ <span class="vi-punc">[</span>
41
+ <span v-for="(item, i) in value" :key="i" class="vi-indent">
42
+ <ValueInspector :value="item" />
43
+ <span v-if="i < value.length - 1" class="vi-punc">,</span>
44
+ </span>
45
+ <span class="vi-punc">]</span>
46
+ </span>
47
+ </span>
48
+
49
+ <!-- object -->
50
+ <span v-else-if="typeof value === 'object'">
51
+ <span v-if="compact || !open">
52
+ <span class="vi-punc">{</span>
53
+ <span class="vi-dim">
54
+ {{
55
+ Object.keys(value as object)
56
+ .slice(0, 3)
57
+ .join(', ')
58
+ }}{{ Object.keys(value as object).length > 3 ? '…' : '' }}
59
+ </span>
60
+ <span class="vi-punc">}</span>
61
+ <button v-if="!compact" class="vi-toggle" @click.stop="open = true">▸</button>
62
+ </span>
63
+ <span v-else class="vi-block">
64
+ <button class="vi-toggle" @click.stop="open = false">▾</button>
65
+ <span class="vi-punc">{</span>
66
+ <span v-for="(v, k, i) in value as Record<string, unknown>" :key="k" class="vi-indent">
67
+ <span class="vi-key">{{ k }}</span>
68
+ <span class="vi-punc">:</span>
69
+ <ValueInspector :value="v" />
70
+ <span v-if="i < Object.keys(value as object).length - 1" class="vi-punc">,</span>
71
+ </span>
72
+ <span class="vi-punc">}</span>
73
+ </span>
74
+ </span>
75
+
76
+ <!-- fallback -->
77
+ <span v-else class="vi-dim">{{ String(value) }}</span>
78
+ </span>
79
+ </template>
80
+
81
+ <style scoped>
82
+ .vi {
83
+ font-family: var(--font-mono);
84
+ font-size: 12px;
85
+ }
86
+ .vi-null {
87
+ color: #888;
88
+ }
89
+ .vi-bool {
90
+ color: #534ab7;
91
+ }
92
+ .vi-num {
93
+ color: #ba7517;
94
+ }
95
+ .vi-str {
96
+ color: #0f6e56;
97
+ }
98
+ .vi-key {
99
+ color: #185195;
100
+ }
101
+ .vi-punc {
102
+ color: #888;
103
+ }
104
+ .vi-dim {
105
+ color: #888;
106
+ font-style: italic;
107
+ }
108
+ .vi-toggle {
109
+ background: none;
110
+ border: none;
111
+ cursor: pointer;
112
+ font-size: 10px;
113
+ color: #888;
114
+ padding: 0 2px;
115
+ }
116
+ .vi-block {
117
+ display: inline-flex;
118
+ flex-direction: column;
119
+ }
120
+ .vi-indent {
121
+ padding-left: 12px;
122
+ display: block;
123
+ }
124
+ </style>
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": "^3.0.0 || ^4.0.0"
6
6
  },
7
- "version": "0.1.11",
7
+ "version": "0.1.13",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
@@ -1,3 +1,8 @@
1
+ export interface RefChangeEvent {
2
+ t: number;
3
+ key: string;
4
+ value: unknown;
5
+ }
1
6
  export interface ComposableEntry {
2
7
  id: string;
3
8
  name: string;
@@ -10,6 +15,13 @@ export interface ComposableEntry {
10
15
  type: 'ref' | 'computed' | 'reactive';
11
16
  value: unknown;
12
17
  }>;
18
+ /** Capped at MAX_HISTORY_PER_ENTRY events, newest last */
19
+ history: RefChangeEvent[];
20
+ /**
21
+ * Keys whose underlying ref/reactive object is shared across multiple
22
+ * instances of this composable — indicates module-level (global) state.
23
+ */
24
+ sharedKeys: string[];
13
25
  watcherCount: number;
14
26
  intervalCount: number;
15
27
  lifecycle: {
@@ -20,6 +32,8 @@ export interface ComposableEntry {
20
32
  };
21
33
  file: string;
22
34
  line: number;
35
+ /** Route path the composable was registered on, e.g. "/products". */
36
+ route: string;
23
37
  }
24
38
  /**
25
39
  * Registers a new composable entry, updates an existing one, or retrieves all entries.
@@ -31,8 +45,15 @@ export interface ComposableEntry {
31
45
  */
32
46
  export declare function setupComposableRegistry(): {
33
47
  register: (entry: ComposableEntry) => void;
48
+ registerLiveRefs: (id: string, refs: Record<string, import("vue").Ref<unknown>>) => void;
49
+ registerRawRefs: (id: string, refs: Record<string, unknown>) => void;
50
+ onComposableChange: (cb: () => void) => void;
51
+ clear: () => void;
52
+ setRoute: (path: string) => void;
53
+ getRoute: () => string;
34
54
  update: (id: string, patch: Partial<ComposableEntry>) => void;
35
55
  getAll: () => ComposableEntry[];
56
+ editValue: (id: string, key: string, value: unknown) => void;
36
57
  };
37
58
  export declare function __trackComposable<T>(name: string, callFn: () => T, meta: {
38
59
  file: string;