nuxt-devtools-observatory 0.1.31 → 0.1.33

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 (44) hide show
  1. package/README.md +79 -46
  2. package/client/.env.example +1 -0
  3. package/client/dist/assets/index-BqKYgjVB.js +20 -0
  4. package/client/dist/assets/index-bs1JBJ2u.css +1 -0
  5. package/client/dist/index.html +2 -2
  6. package/client/src/App.vue +4 -0
  7. package/client/src/components/Flamegraph.vue +7 -8
  8. package/client/src/components/SpanInspector.vue +1 -1
  9. package/client/src/components/TraceFilter.vue +0 -2
  10. package/client/src/components/WaterfallView.vue +1 -1
  11. package/client/src/composables/composable-search.ts +127 -0
  12. package/client/src/composables/trace-render-aggregation.ts +263 -0
  13. package/client/src/composables/useExportImport.ts +63 -0
  14. package/client/src/composables/useTraceFilter.ts +1 -5
  15. package/client/src/composables/useVirtualizationConfig.ts +40 -0
  16. package/client/src/composables/useVirtualizationFlags.ts +129 -0
  17. package/client/src/stores/observatory.ts +9 -1
  18. package/client/src/views/ComposableTracker.vue +273 -97
  19. package/client/src/views/FetchDashboard.vue +181 -16
  20. package/client/src/views/ProvideInjectGraph.vue +41 -18
  21. package/client/src/views/RenderHeatmap.vue +392 -76
  22. package/client/src/views/TraceViewer.vue +797 -14
  23. package/client/src/views/TransitionTimeline.vue +112 -19
  24. package/dist/module.d.mts +5 -0
  25. package/dist/module.json +1 -1
  26. package/dist/module.mjs +12 -23
  27. package/dist/runtime/composables/composable-registry.d.ts +19 -0
  28. package/dist/runtime/composables/composable-registry.js +63 -5
  29. package/dist/runtime/composables/render-registry.js +23 -13
  30. package/dist/runtime/instrumentation/asyncData.d.ts +1 -1
  31. package/dist/runtime/instrumentation/fetch.d.ts +7 -1
  32. package/dist/runtime/instrumentation/fetch.js +22 -1
  33. package/dist/runtime/nitro/fetch-capture.d.ts +1 -2
  34. package/dist/runtime/nitro/fetch-capture.js +85 -7
  35. package/dist/runtime/nitro/ssr-trace-store.d.ts +85 -0
  36. package/dist/runtime/nitro/ssr-trace-store.js +84 -0
  37. package/dist/runtime/plugin.js +48 -1
  38. package/dist/runtime/test-bridge.d.ts +18 -0
  39. package/dist/runtime/test-bridge.js +86 -0
  40. package/dist/runtime/tracing/trace.d.ts +1 -1
  41. package/package.json +18 -3
  42. package/client/.env +0 -17
  43. package/client/dist/assets/index-BuMXDBO9.js +0 -17
  44. package/client/dist/assets/index-CwcspZ6w.css +0 -1
@@ -1,7 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, defineComponent, h, ref, watch, type VNode } from 'vue'
3
+ import { useVirtualizer } from '@tanstack/vue-virtual'
4
+ import { useVirtualizationConfig } from '@observatory-client/composables/useVirtualizationConfig'
5
+ import { useVirtualizationFlags } from '@observatory-client/composables/useVirtualizationFlags'
3
6
  import { useResizablePane } from '@observatory-client/composables/useResizablePane'
4
7
  import { useObservatoryData, openInEditor as openInEditorFromStore } from '@observatory-client/stores/observatory'
8
+ import { exportJson, importJson } from '@observatory-client/composables/useExportImport'
9
+ import type { ObservatoryExportFile } from '@observatory-client/composables/useExportImport'
5
10
  import type { RenderEntry, RenderEvent } from '@observatory/types/snapshot'
6
11
 
7
12
  interface ComponentNode {
@@ -24,6 +29,37 @@ interface ComponentNode {
24
29
  route: string
25
30
  }
26
31
 
32
+ interface VisibleTreeRow {
33
+ node: ComponentNode
34
+ primaryBadge: string | null
35
+ metricValue: string
36
+ metricLabel: string
37
+ hot: boolean
38
+ }
39
+
40
+ function nodeBadges(node: ComponentNode): string[] {
41
+ const badges: string[] = []
42
+ const normalizedElement = node.element?.toLowerCase()
43
+
44
+ if (node.element && node.element !== node.label && !['div', 'span', 'p'].includes(normalizedElement ?? '')) {
45
+ badges.push(node.element)
46
+ }
47
+
48
+ if (node.file !== 'unknown') {
49
+ const fileBadge =
50
+ node.file
51
+ .split('/')
52
+ .pop()
53
+ ?.replace(/\.vue$/i, '') ?? node.file
54
+
55
+ if (fileBadge !== node.label && !badges.includes(fileBadge)) {
56
+ badges.push(fileBadge)
57
+ }
58
+ }
59
+
60
+ return badges
61
+ }
62
+
27
63
  const TreeNode = defineComponent({
28
64
  name: 'TreeNode',
29
65
  props: {
@@ -56,24 +92,7 @@ const TreeNode = defineComponent({
56
92
  const canExpand = node.children.length > 0
57
93
  const metric = props.mode === 'count' ? `${node.rerenders + node.mountCount}` : `${node.avgMs.toFixed(1)}ms`
58
94
  const metricLabel = props.mode === 'count' ? 'renders' : 'avg'
59
- const badges = []
60
- const normalizedElement = node.element?.toLowerCase()
61
-
62
- if (node.element && node.element !== node.label && !['div', 'span', 'p'].includes(normalizedElement ?? '')) {
63
- badges.push(node.element)
64
- }
65
-
66
- if (node.file !== 'unknown') {
67
- const fileBadge =
68
- node.file
69
- .split('/')
70
- .pop()
71
- ?.replace(/\.vue$/i, '') ?? node.file
72
-
73
- if (fileBadge !== node.label && !badges.includes(fileBadge)) {
74
- badges.push(fileBadge)
75
- }
76
- }
95
+ const badges = nodeBadges(node)
77
96
 
78
97
  return h('div', { class: 'tree-node' }, [
79
98
  h(
@@ -197,12 +216,17 @@ const activeThreshold = computed({
197
216
  })
198
217
  const activeHotOnly = ref(false)
199
218
  const frozen = ref(false)
219
+ const isImportedSnapshot = ref(false)
200
220
  const search = ref('')
201
221
  const activeSelectedId = ref<string | null>(null)
202
222
  const activeRootId = ref<string | null>(null)
203
223
  const expandedIds = ref<Set<string>>(new Set())
204
224
  const frozenSnapshot = ref<RenderEntry[]>([])
205
225
  const expansionReady = ref(false)
226
+ const treeFrameRef = ref<HTMLElement | null>(null)
227
+
228
+ const { effective: virtualizationFlags } = useVirtualizationFlags()
229
+ const { preset: virtualizationPreset } = useVirtualizationConfig({ rowHeight: 34, overscan: 6 })
206
230
 
207
231
  function displayLabel(entry: RenderEntry) {
208
232
  if (entry.name && entry.name !== 'unknown' && !/^Component#\d+$/.test(entry.name)) {
@@ -300,33 +324,55 @@ function flatten(nodes: ComponentNode[]) {
300
324
  return flat
301
325
  }
302
326
 
303
- function countSubtree(node: ComponentNode): number {
304
- return 1 + node.children.reduce((sum, child) => sum + countSubtree(child), 0)
305
- }
327
+ function buildSubtreeSizeMap(roots: ComponentNode[]) {
328
+ const sizes = new Map<string, number>()
306
329
 
307
- function collectIds(node: ComponentNode, target = new Set<string>()) {
308
- target.add(node.id)
309
- node.children.forEach((child) => collectIds(child, target))
330
+ for (const root of roots) {
331
+ const stack: Array<{ node: ComponentNode; visited: boolean }> = [{ node: root, visited: false }]
310
332
 
311
- return target
312
- }
333
+ while (stack.length) {
334
+ const current = stack.pop()!
335
+
336
+ if (!current.visited) {
337
+ stack.push({ node: current.node, visited: true })
338
+
339
+ for (let i = current.node.children.length - 1; i >= 0; i--) {
340
+ stack.push({ node: current.node.children[i], visited: false })
341
+ }
313
342
 
314
- function pathToNode(node: ComponentNode, targetId: string, trail: string[] = []): string[] | null {
315
- const nextTrail = [...trail, node.id]
343
+ continue
344
+ }
345
+
346
+ let size = 1
347
+
348
+ for (const child of current.node.children) {
349
+ size += sizes.get(child.id) ?? 1
350
+ }
316
351
 
317
- if (node.id === targetId) {
318
- return nextTrail
352
+ sizes.set(current.node.id, size)
353
+ }
319
354
  }
320
355
 
321
- for (const child of node.children) {
322
- const childTrail = pathToNode(child, targetId, nextTrail)
356
+ return sizes
357
+ }
358
+
359
+ function buildRootLookup(roots: ComponentNode[]) {
360
+ const lookup = new Map<string, string>()
323
361
 
324
- if (childTrail) {
325
- return childTrail
362
+ for (const root of roots) {
363
+ const stack: ComponentNode[] = [root]
364
+
365
+ while (stack.length) {
366
+ const node = stack.pop()!
367
+ lookup.set(node.id, root.id)
368
+
369
+ for (let i = node.children.length - 1; i >= 0; i--) {
370
+ stack.push(node.children[i])
371
+ }
326
372
  }
327
373
  }
328
374
 
329
- return null
375
+ return lookup
330
376
  }
331
377
 
332
378
  function findFirstHotNode(node: ComponentNode): ComponentNode | null {
@@ -473,6 +519,37 @@ const displayEntries = computed(() => (frozen.value ? frozenSnapshot.value : ren
473
519
  const rootNodes = computed(() => buildNodes(displayEntries.value))
474
520
  const rootMap = computed(() => new Map(rootNodes.value.map((node) => [node.id, node])))
475
521
  const allComponents = computed(() => flatten(rootNodes.value))
522
+ const allComponentsById = computed(() => new Map(allComponents.value.map((node) => [node.id, node])))
523
+ const allComponentIds = computed(() => new Set(allComponents.value.map((node) => node.id)))
524
+ const subtreeSizeById = computed(() => buildSubtreeSizeMap(rootNodes.value))
525
+ const rootIdByNodeId = computed(() => buildRootLookup(rootNodes.value))
526
+
527
+ function pathToNodeWithinRoot(targetId: string, rootId?: string | null): string[] {
528
+ const path: string[] = []
529
+ let current = allComponentsById.value.get(targetId)
530
+
531
+ while (current) {
532
+ path.push(current.id)
533
+
534
+ if (rootId && current.id === rootId) {
535
+ break
536
+ }
537
+
538
+ if (!current.parentId) {
539
+ break
540
+ }
541
+
542
+ current = allComponentsById.value.get(current.parentId)
543
+ }
544
+
545
+ path.reverse()
546
+
547
+ if (rootId && path[0] !== rootId) {
548
+ return []
549
+ }
550
+
551
+ return path
552
+ }
476
553
 
477
554
  const filteredRoots = computed(() => {
478
555
  const term = search.value.trim()
@@ -480,9 +557,11 @@ const filteredRoots = computed(() => {
480
557
  return rootNodes.value.filter((root) => isVisibleRoot(root, term))
481
558
  })
482
559
 
560
+ const filteredRootById = computed(() => new Map(filteredRoots.value.map((node) => [node.id, node])))
561
+
483
562
  const activeRoot = computed(() => {
484
563
  if (activeRootId.value) {
485
- return filteredRoots.value.find((node) => node.id === activeRootId.value) ?? rootMap.value.get(activeRootId.value) ?? null
564
+ return filteredRootById.value.get(activeRootId.value) ?? rootMap.value.get(activeRootId.value) ?? null
486
565
  }
487
566
 
488
567
  return filteredRoots.value[0] ?? rootNodes.value[0] ?? null
@@ -502,11 +581,99 @@ const visibleTreeRoots = computed(() => {
502
581
  return [visibleActiveRoot.value]
503
582
  })
504
583
 
584
+ const virtualizedTreeEnabled = computed(() => virtualizationFlags.value.heatmap)
585
+
586
+ function flattenVisibleTree(root: ComponentNode | null, expanded: Set<string>) {
587
+ if (!root) {
588
+ return [] as ComponentNode[]
589
+ }
590
+
591
+ const rows: ComponentNode[] = []
592
+
593
+ function walk(node: ComponentNode) {
594
+ rows.push(node)
595
+
596
+ if (!expanded.has(node.id)) {
597
+ return
598
+ }
599
+
600
+ node.children.forEach(walk)
601
+ }
602
+
603
+ walk(root)
604
+
605
+ return rows
606
+ }
607
+
608
+ const expandedVisibleNodes = computed(() => flattenVisibleTree(visibleActiveRoot.value, expandedIds.value))
609
+
610
+ const treeVirtualizerOptions = computed(() => ({
611
+ count: expandedVisibleNodes.value.length,
612
+ getScrollElement: () => treeFrameRef.value,
613
+ estimateSize: () => virtualizationPreset.value.rowHeight,
614
+ overscan: virtualizationPreset.value.overscan,
615
+ }))
616
+
617
+ const treeVirtualizer = useVirtualizer(treeVirtualizerOptions)
618
+
619
+ const treeVirtualItems = computed(() => {
620
+ if (!virtualizedTreeEnabled.value) {
621
+ return []
622
+ }
623
+
624
+ return treeVirtualizer.value.getVirtualItems()
625
+ })
626
+
627
+ const topTreePadding = computed(() => {
628
+ if (!virtualizedTreeEnabled.value || treeVirtualItems.value.length === 0) {
629
+ return 0
630
+ }
631
+
632
+ return treeVirtualItems.value[0].start
633
+ })
634
+
635
+ const bottomTreePadding = computed(() => {
636
+ if (!virtualizedTreeEnabled.value || treeVirtualItems.value.length === 0) {
637
+ return 0
638
+ }
639
+
640
+ const total = treeVirtualizer.value.getTotalSize()
641
+ const last = treeVirtualItems.value[treeVirtualItems.value.length - 1]
642
+
643
+ return Math.max(0, total - last.end)
644
+ })
645
+
646
+ const visibleTreeRows = computed(() => {
647
+ if (!virtualizedTreeEnabled.value) {
648
+ return expandedVisibleNodes.value
649
+ }
650
+
651
+ return treeVirtualItems.value
652
+ .map((item) => expandedVisibleNodes.value[item.index])
653
+ .filter((node): node is ComponentNode => Boolean(node))
654
+ })
655
+
656
+ const visibleTreeRowItems = computed<VisibleTreeRow[]>(() => {
657
+ const metricLabel = activeMode.value === 'count' ? 'renders' : 'avg'
658
+
659
+ return visibleTreeRows.value.map((node) => {
660
+ const badges = nodeBadges(node)
661
+
662
+ return {
663
+ node,
664
+ primaryBadge: badges[0] ?? null,
665
+ metricValue: activeMode.value === 'count' ? `${node.rerenders + node.mountCount}` : `${node.avgMs.toFixed(1)}ms`,
666
+ metricLabel,
667
+ hot: isHot(node),
668
+ }
669
+ })
670
+ })
671
+
505
672
  const appEntries = computed(() =>
506
673
  filteredRoots.value.map((root, index) => ({
507
674
  id: root.id,
508
675
  label: `App ${index + 1}`,
509
- meta: `${countSubtree(root)} nodes`,
676
+ meta: `${subtreeSizeById.value.get(root.id) ?? 1} nodes`,
510
677
  root,
511
678
  }))
512
679
  )
@@ -526,7 +693,31 @@ const knownRoutes = computed(() => {
526
693
  return [...routes].sort()
527
694
  })
528
695
 
529
- const activeSelected = computed(() => allComponents.value.find((node) => node.id === activeSelectedId.value) ?? null)
696
+ const activeSelected = computed(() => {
697
+ if (!activeSelectedId.value) {
698
+ return null
699
+ }
700
+
701
+ return allComponentsById.value.get(activeSelectedId.value) ?? null
702
+ })
703
+
704
+ const activeSelectedTimelineRecent = computed(() => {
705
+ const timeline = activeSelected.value?.timeline ?? []
706
+
707
+ if (!timeline.length) {
708
+ return [] as Array<{ key: string; event: RenderEvent }>
709
+ }
710
+
711
+ const recent: Array<{ key: string; event: RenderEvent }> = []
712
+ const end = Math.max(timeline.length - 30, 0)
713
+
714
+ for (let i = timeline.length - 1; i >= end; i--) {
715
+ const event = timeline[i]
716
+ recent.push({ key: `${event.kind}-${event.t}-${i}`, event })
717
+ }
718
+
719
+ return recent
720
+ })
530
721
  const totalRenders = computed(() => allComponents.value.reduce((sum, node) => sum + node.rerenders + node.mountCount, 0))
531
722
  const hotCount = computed(() => allComponents.value.filter((node) => isHot(node)).length)
532
723
  const avgTime = computed(() => {
@@ -557,12 +748,11 @@ watch(
557
748
  activeRootId.value = roots[0].id
558
749
  }
559
750
 
560
- if (activeSelectedId.value && !allComponents.value.some((node) => node.id === activeSelectedId.value)) {
751
+ if (activeSelectedId.value && !allComponentIds.value.has(activeSelectedId.value)) {
561
752
  activeSelectedId.value = null
562
753
  }
563
754
 
564
- const validIds = new Set(allComponents.value.map((node) => node.id))
565
- const preserved = new Set([...expandedIds.value].filter((id) => validIds.has(id)))
755
+ const preserved = new Set([...expandedIds.value].filter((id) => allComponentIds.value.has(id)))
566
756
 
567
757
  if (!expansionReady.value) {
568
758
  expandedIds.value = defaultExpandedIds(activeRoot.value)
@@ -572,7 +762,7 @@ watch(
572
762
  }
573
763
 
574
764
  if (!search.value.trim() && activeSelectedId.value && activeRoot.value) {
575
- const selectedPath = pathToNode(activeRoot.value, activeSelectedId.value) ?? []
765
+ const selectedPath = pathToNodeWithinRoot(activeSelectedId.value, activeRoot.value.id)
576
766
  selectedPath.forEach((id) => preserved.add(id))
577
767
  }
578
768
 
@@ -595,8 +785,8 @@ watch(search, (term) => {
595
785
  }
596
786
 
597
787
  if (activeSelectedId.value) {
598
- const selectedPath = pathToNode(activeRoot.value, activeSelectedId.value)
599
- expandedIds.value = selectedPath ? new Set(selectedPath) : defaultExpandedIds(activeRoot.value)
788
+ const selectedPath = pathToNodeWithinRoot(activeSelectedId.value, activeRoot.value.id)
789
+ expandedIds.value = selectedPath.length ? new Set(selectedPath) : defaultExpandedIds(activeRoot.value)
600
790
 
601
791
  return
602
792
  }
@@ -631,17 +821,18 @@ watch([activeHotOnly, activeThreshold, activeMode, filteredRoots], () => {
631
821
  activeSelectedId.value = firstHot.id
632
822
  }
633
823
 
634
- expandedIds.value = new Set(pathToNode(topLevelRoot, firstHot.id) ?? [topLevelRoot.id])
824
+ expandedIds.value = new Set(pathToNodeWithinRoot(firstHot.id, topLevelRoot.id))
635
825
  })
636
826
 
637
827
  function selectNode(node: ComponentNode) {
638
828
  activeSelectedId.value = node.id
639
829
 
640
- const topLevelRoot = rootNodes.value.find((root) => collectIds(root).has(node.id))
830
+ const rootId = rootIdByNodeId.value.get(node.id)
831
+ const topLevelRoot = rootId ? rootMap.value.get(rootId) : null
641
832
 
642
833
  if (topLevelRoot) {
643
834
  activeRootId.value = topLevelRoot.id
644
- expandedIds.value = new Set(pathToNode(topLevelRoot, node.id) ?? [topLevelRoot.id])
835
+ expandedIds.value = new Set(pathToNodeWithinRoot(node.id, topLevelRoot.id))
645
836
  }
646
837
  }
647
838
 
@@ -671,6 +862,7 @@ function updateSearch(event: Event) {
671
862
  function toggleFreeze() {
672
863
  if (frozen.value) {
673
864
  frozen.value = false
865
+ isImportedSnapshot.value = false
674
866
  frozenSnapshot.value = []
675
867
 
676
868
  return
@@ -680,6 +872,45 @@ function toggleFreeze() {
680
872
  frozen.value = true
681
873
  }
682
874
 
875
+ function handleExport() {
876
+ exportJson(`observatory-renders-${Date.now()}.json`, {
877
+ type: 'observatory-renders',
878
+ version: '1',
879
+ exportedAt: Date.now(),
880
+ count: displayEntries.value.length,
881
+ data: displayEntries.value,
882
+ })
883
+ }
884
+
885
+ async function handleImport() {
886
+ let parsed: unknown
887
+
888
+ try {
889
+ parsed = await importJson()
890
+ } catch (err) {
891
+ if (err instanceof Error && err.message !== 'cancelled') {
892
+ alert(`Import failed: ${err.message}`)
893
+ }
894
+ return
895
+ }
896
+
897
+ const file = parsed as ObservatoryExportFile<RenderEntry>
898
+
899
+ if (
900
+ file?.type !== 'observatory-renders' ||
901
+ file?.version !== '1' ||
902
+ !Array.isArray(file?.data) ||
903
+ (file.data.length > 0 && (file.data[0]?.uid === undefined || !file.data[0]?.name || !file.data[0]?.file))
904
+ ) {
905
+ alert('Invalid observatory renders file.')
906
+ return
907
+ }
908
+
909
+ frozenSnapshot.value = file.data
910
+ frozen.value = true
911
+ isImportedSnapshot.value = true
912
+ }
913
+
683
914
  function basename(file: string) {
684
915
  return (
685
916
  file
@@ -732,8 +963,10 @@ function formatTimestamp(t: number): string {
732
963
  <option v-for="r in knownRoutes" :key="r" :value="r">{{ r }}</option>
733
964
  </select>
734
965
  <button :class="{ active: frozen }" class="render-heatmap__freeze tracker-toolbar__spacer" @click="toggleFreeze">
735
- {{ frozen ? 'unfreeze' : 'freeze snapshot' }}
966
+ {{ frozen && isImportedSnapshot ? 'unfreeze (imported)' : frozen ? 'unfreeze' : 'freeze snapshot' }}
736
967
  </button>
968
+ <button class="render-heatmap__action-btn" title="Export render data as JSON" @click="handleExport">↓ export</button>
969
+ <button class="render-heatmap__action-btn" title="Import render data from JSON file" @click="handleImport">↑ import</button>
737
970
  </div>
738
971
 
739
972
  <div class="render-heatmap__stats tracker-stats-row">
@@ -784,21 +1017,89 @@ function formatTimestamp(t: number): string {
784
1017
  />
785
1018
  </div>
786
1019
 
787
- <div class="render-heatmap__tree-frame">
1020
+ <div ref="treeFrameRef" class="render-heatmap__tree-frame">
788
1021
  <div class="render-heatmap__tree-canvas tree-canvas">
789
- <TreeNode
790
- v-for="root in visibleTreeRoots"
791
- :key="root.id"
792
- :node="root"
793
- :mode="activeMode"
794
- :threshold="activeThreshold"
795
- :selected="activeSelected?.id"
796
- :expanded-ids="expandedIds"
797
- @select="selectNode"
798
- @toggle="toggleNode"
799
- />
1022
+ <template v-if="virtualizedTreeEnabled">
1023
+ <div
1024
+ v-if="topTreePadding > 0"
1025
+ class="render-heatmap__tree-spacer"
1026
+ :style="{ height: `${topTreePadding}px` }"
1027
+ aria-hidden="true"
1028
+ />
1029
+ <div
1030
+ v-for="row in visibleTreeRowItems"
1031
+ :key="row.node.id"
1032
+ class="tree-row"
1033
+ :class="{ selected: activeSelected?.id === row.node.id, hot: row.hot }"
1034
+ :style="{ '--tree-depth': String(row.node.depth) }"
1035
+ @click="selectNode(row.node)"
1036
+ >
1037
+ <span class="tree-rail" aria-hidden="true" />
1038
+ <button
1039
+ class="tree-toggle"
1040
+ :class="{ empty: !row.node.children.length }"
1041
+ :disabled="!row.node.children.length"
1042
+ @click.stop="row.node.children.length ? toggleNode(row.node.id) : undefined"
1043
+ >
1044
+ {{ row.node.children.length ? (expandedIds.has(row.node.id) ? '⌄' : '›') : '' }}
1045
+ </button>
1046
+ <div class="tree-copy">
1047
+ <span class="tree-name mono" :title="row.node.label">{{ row.node.label }}</span>
1048
+ <div v-if="row.primaryBadge" class="tree-badges">
1049
+ <span class="tree-badge mono" :title="row.primaryBadge">{{ row.primaryBadge }}</span>
1050
+ </div>
1051
+ </div>
1052
+ <div class="tree-metrics mono">
1053
+ <span
1054
+ v-if="row.node.isPersistent"
1055
+ class="tree-persistent-pill"
1056
+ title="Layout / persistent component — survives navigation"
1057
+ >
1058
+ persistent
1059
+ </span>
1060
+ <span
1061
+ v-if="row.node.isHydrationMount"
1062
+ class="tree-hydration-pill"
1063
+ title="First mount was SSR hydration — not a user-triggered render"
1064
+ >
1065
+ hydrated
1066
+ </span>
1067
+ <span class="tree-metric-pill">
1068
+ {{ row.metricValue }}
1069
+ {{ row.metricLabel }}
1070
+ </span>
1071
+ <button
1072
+ v-if="row.node.file && row.node.file !== 'unknown'"
1073
+ class="tree-jump-btn"
1074
+ :title="`Open ${row.node.file} in editor`"
1075
+ @click.stop="openInEditor(row.node.file)"
1076
+ >
1077
+
1078
+ </button>
1079
+ </div>
1080
+ </div>
1081
+ <div
1082
+ v-if="bottomTreePadding > 0"
1083
+ class="render-heatmap__tree-spacer"
1084
+ :style="{ height: `${bottomTreePadding}px` }"
1085
+ aria-hidden="true"
1086
+ />
1087
+ </template>
1088
+ <template v-else>
1089
+ <TreeNode
1090
+ v-for="root in visibleTreeRoots"
1091
+ :key="root.id"
1092
+ :node="root"
1093
+ :mode="activeMode"
1094
+ :threshold="activeThreshold"
1095
+ :selected="activeSelected?.id"
1096
+ :expanded-ids="expandedIds"
1097
+ @select="selectNode"
1098
+ @toggle="toggleNode"
1099
+ />
1100
+ </template>
800
1101
  </div>
801
- <div v-if="!visibleTreeRoots.length" class="render-heatmap__detail-empty">
1102
+ <div v-if="!expandedVisibleNodes.length" class="render-heatmap__detail-empty">
802
1103
  {{ connected ? 'No render activity recorded yet.' : 'Waiting for connection to the Nuxt app…' }}
803
1104
  </div>
804
1105
  </div>
@@ -906,16 +1207,14 @@ function formatTimestamp(t: number): string {
906
1207
  </div>
907
1208
  <div v-if="!activeSelected.timeline.length" class="muted text-sm">no timeline events yet</div>
908
1209
  <div v-else class="render-heatmap__timeline-list">
909
- <div
910
- v-for="(event, idx) in [...activeSelected.timeline].reverse().slice(0, 30)"
911
- :key="idx"
912
- class="render-heatmap__timeline-row"
913
- >
914
- <span class="render-heatmap__timeline-kind mono" :class="event.kind">{{ event.kind }}</span>
915
- <span class="render-heatmap__timeline-time mono muted">{{ formatTimestamp(event.t) }}</span>
916
- <span class="render-heatmap__timeline-dur mono">{{ formatMs(event.durationMs) }}</span>
917
- <span v-if="event.triggerKey" class="render-heatmap__timeline-trigger mono muted">{{ event.triggerKey }}</span>
918
- <span class="render-heatmap__timeline-route mono muted">{{ event.route }}</span>
1210
+ <div v-for="row in activeSelectedTimelineRecent" :key="row.key" class="render-heatmap__timeline-row">
1211
+ <span class="render-heatmap__timeline-kind mono" :class="row.event.kind">{{ row.event.kind }}</span>
1212
+ <span class="render-heatmap__timeline-time mono muted">{{ formatTimestamp(row.event.t) }}</span>
1213
+ <span class="render-heatmap__timeline-dur mono">{{ formatMs(row.event.durationMs) }}</span>
1214
+ <span v-if="row.event.triggerKey" class="render-heatmap__timeline-trigger mono muted">
1215
+ {{ row.event.triggerKey }}
1216
+ </span>
1217
+ <span class="render-heatmap__timeline-route mono muted">{{ row.event.route }}</span>
919
1218
  </div>
920
1219
  <div v-if="activeSelected.timeline.length > 30" class="render-heatmap__compact-muted muted text-sm">
921
1220
  … {{ activeSelected.timeline.length - 30 }} earlier events
@@ -952,6 +1251,23 @@ function formatTimestamp(t: number): string {
952
1251
  width: 90px;
953
1252
  }
954
1253
 
1254
+ .render-heatmap__action-btn {
1255
+ padding: 3px 8px;
1256
+ background: none;
1257
+ border: 1px solid var(--border);
1258
+ color: var(--text-secondary);
1259
+ cursor: pointer;
1260
+ font-size: 11px;
1261
+ border-radius: 3px;
1262
+ transition: all 0.12s;
1263
+ font-family: var(--mono);
1264
+ }
1265
+
1266
+ .render-heatmap__action-btn:hover {
1267
+ background: var(--bg-secondary);
1268
+ color: var(--text);
1269
+ }
1270
+
955
1271
  .stat-sub {
956
1272
  margin-top: var(--tracker-space-1);
957
1273
  font-size: var(--tracker-font-size-sm);
@@ -965,11 +1281,6 @@ function formatTimestamp(t: number): string {
965
1281
  min-height: 0;
966
1282
  }
967
1283
 
968
- .render-heatmap__roots-panel,
969
- .render-heatmap__detail-panel {
970
- flex-shrink: 0;
971
- }
972
-
973
1284
  .render-heatmap__roots-panel {
974
1285
  width: 240px;
975
1286
  margin-right: 12px;
@@ -986,6 +1297,7 @@ function formatTimestamp(t: number): string {
986
1297
 
987
1298
  .render-heatmap__roots-panel,
988
1299
  .render-heatmap__detail-panel {
1300
+ flex-shrink: 0;
989
1301
  display: flex;
990
1302
  flex-direction: column;
991
1303
  overflow: auto;
@@ -1072,6 +1384,10 @@ function formatTimestamp(t: number): string {
1072
1384
  width: max-content;
1073
1385
  }
1074
1386
 
1387
+ .render-heatmap__tree-spacer {
1388
+ width: 100%;
1389
+ }
1390
+
1075
1391
  :deep(.tree-node) {
1076
1392
  margin-bottom: 4px;
1077
1393
  }