Skip to content

Commit 10a5697

Browse files
committed
chore: Add duration tracking in html_component.go
1 parent be27ee9 commit 10a5697

File tree

8 files changed

+446
-20
lines changed

8 files changed

+446
-20
lines changed

cmd/rfw/plugins/devtools/devtools.js

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ const markup = `
5252
.node:hover{background:var(--tile-hover)}
5353
.node .kind{font-size:11px; color:var(--accent-2); padding:2px 6px; border:1px solid var(--border-2); border-radius:999px; background:var(--tile-bg)}
5454
.node .name{font-weight:600}
55-
.node .time{margin-left:auto; font-variant-numeric:tabular-nums; color:var(--rose-300)}
55+
.node .meta{margin-left:auto; display:flex; align-items:center; gap:6px; font-variant-numeric:tabular-nums; color:var(--rose-300)}
56+
.node .meta span{display:inline-flex; align-items:center; gap:4px; padding:2px 8px; border-radius:999px; border:1px solid var(--tile-border); background:var(--tile-bg)}
5657
5758
.rfw-detail{flex:1;background:var(--chip-bg);display:flex;flex-direction:column}
5859
.rfw-detail .rfw-subheader{display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border)}
@@ -61,6 +62,10 @@ const markup = `
6162
.kv b{color:var(--rose-200)}
6263
6364
.mono{font-variant-numeric:tabular-nums}
65+
.timeline{display:flex; flex-direction:column; gap:6px; font-variant-numeric:tabular-nums}
66+
.timeline-item{display:flex; align-items:center; gap:8px}
67+
.timeline-item .mono{background:var(--tile-bg); border:1px solid var(--tile-border); padding:2px 6px; border-radius:6px}
68+
.timeline-item .duration{opacity:.75}
6469
6570
/* Network panel */
6671
.net-list{flex:1; overflow:auto; padding:8px}
@@ -402,10 +407,24 @@ function renderTree(list, root = true) {
402407
const el = document.createElement("div");
403408
el.className = "node";
404409
el.dataset.id = node.id;
410+
const metrics = [];
411+
if (typeof node.average === "number" && Number.isFinite(node.average)) {
412+
metrics.push(`${node.average.toFixed(1)} ms avg`);
413+
} else if (typeof node.time === "number" && Number.isFinite(node.time)) {
414+
metrics.push(`${node.time.toFixed(1)} ms`);
415+
}
416+
if (typeof node.updates === "number" && node.updates > 0) {
417+
metrics.push(`${node.updates}×`);
418+
}
419+
const meta = metrics.length
420+
? `<span class="meta">${metrics
421+
.map((txt) => `<span>${escapeHTML(txt)}</span>`)
422+
.join("")}</span>`
423+
: "";
405424
el.innerHTML = `
406-
<span class="kind">${node.kind}</span>
407-
<span class="name">${node.name}</span>
408-
<span class="time">${(node.time || 0).toFixed(1)} ms</span>
425+
<span class="kind">${escapeHTML(node.kind || "")}</span>
426+
<span class="name">${escapeHTML(node.name || "")}</span>
427+
${meta}
409428
`;
410429
el.addEventListener("click", () => selectNode(node));
411430
frag.appendChild(el);
@@ -429,10 +448,15 @@ function selectNode(n) {
429448
detailKind.textContent = n.kind;
430449
const rows = [];
431450
rows.push(renderRow("Path", n.path || ""));
432-
rows.push(renderRow("Render time", `${(n.time || 0).toFixed(2)} ms`));
451+
if (typeof n.updates === "number") rows.push(renderRow("Updates", String(n.updates)));
452+
if (typeof n.average === "number" && Number.isFinite(n.average))
453+
rows.push(renderRow("Average render", formatMs(n.average)));
454+
if (typeof n.time === "number" && Number.isFinite(n.time))
455+
rows.push(renderRow("Last render", formatMs(n.time)));
456+
if (typeof n.total === "number" && Number.isFinite(n.total))
457+
rows.push(renderRow("Total render", formatMs(n.total)));
433458
if (n.owner) rows.push(renderRow("Owner", n.owner));
434459
if (n.hostComponent) rows.push(renderRow("Host component", n.hostComponent));
435-
if (typeof n.updates === "number") rows.push(renderRow("Updates", String(n.updates)));
436460
if (n.props && Object.keys(n.props).length)
437461
rows.push(renderJSONRow("Props", n.props));
438462
if (n.slots && Object.keys(n.slots).length)
@@ -448,6 +472,8 @@ function selectNode(n) {
448472
rows.push(renderJSONRow("Store state", n.store.state));
449473
}
450474
}
475+
if (Array.isArray(n.timeline) && n.timeline.length)
476+
rows.push(renderTimelineRow("Timeline", n.timeline));
451477
detailKV.innerHTML = rows.join("") || renderRow("Info", "No metadata available");
452478
}
453479

@@ -461,6 +487,28 @@ function escapeHTML(s) {
461487
);
462488
}
463489

490+
function formatMs(value) {
491+
if (typeof value !== "number" || !Number.isFinite(value)) return "0.00 ms";
492+
const digits = value >= 100 ? 0 : 2;
493+
return `${value.toFixed(digits)} ms`;
494+
}
495+
496+
function renderTimelineRow(label, events) {
497+
const items = events
498+
.map((ev) => {
499+
const offset = formatMs(ev.at ?? 0);
500+
const duration =
501+
typeof ev.duration === "number" && ev.duration > 0
502+
? `<span class="mono duration">${formatMs(ev.duration)}</span>`
503+
: "";
504+
return `<div class="timeline-item"><span class="mono">${offset}</span><span>${escapeHTML(
505+
ev.kind || "",
506+
)}</span>${duration}</div>`;
507+
})
508+
.join("");
509+
return `<b>${escapeHTML(label)}</b><div class="timeline">${items}</div>`;
510+
}
511+
464512
function formatJSON(value) {
465513
try {
466514
return JSON.stringify(value, null, 2);
@@ -501,19 +549,45 @@ function countNodes(list) {
501549
return total;
502550
}
503551

552+
function aggregateRenderAverage(list) {
553+
let total = 0;
554+
let count = 0;
555+
const walk = (nodes) => {
556+
nodes.forEach((n) => {
557+
if (
558+
typeof n.total === "number" &&
559+
typeof n.updates === "number" &&
560+
n.updates > 0
561+
) {
562+
total += n.total;
563+
count += n.updates;
564+
}
565+
if (Array.isArray(n.children) && n.children.length) walk(n.children);
566+
});
567+
};
568+
walk(list);
569+
if (!count) return null;
570+
return total / count;
571+
}
572+
504573
function refreshTree() {
505574
try {
506575
if (typeof globalThis.RFW_DEVTOOLS_TREE === "function") {
507576
const data = JSON.parse(globalThis.RFW_DEVTOOLS_TREE());
508577
renderTree(data);
509578
const k = $("#kpiNodes");
510579
if (k) k.textContent = String(countNodes(data));
580+
const avg = aggregateRenderAverage(data);
581+
const r = $("#kpiRender");
582+
if (r) r.textContent = avg == null ? "n/a" : formatMs(avg);
511583
return;
512584
}
513585
} catch {}
514586
if (treeContainer) treeContainer.textContent = "";
515587
const k = $("#kpiNodes");
516588
if (k) k.textContent = "0";
589+
const r = $("#kpiRender");
590+
if (r) r.textContent = "n/a";
517591
}
518592

519593
let fpsSample = 0,

v1/core/html_component.go

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"sort"
1414
"strings"
1515
"sync"
16+
"time"
1617

1718
"github.com/rfwlab/rfw/v1/dom"
1819
hostclient "github.com/rfwlab/rfw/v1/hostclient"
@@ -58,6 +59,27 @@ type HTMLComponent struct {
5859
provides map[string]any
5960
cache map[string]string
6061
lastCacheKey string
62+
metricsMu sync.Mutex
63+
renderCount int
64+
totalRender time.Duration
65+
lastRender time.Duration
66+
timeline []ComponentTimelineEntry
67+
}
68+
69+
// ComponentStats contains aggregated render metrics for an HTML component.
70+
type ComponentStats struct {
71+
RenderCount int
72+
TotalRender time.Duration
73+
LastRender time.Duration
74+
AverageRender time.Duration
75+
Timeline []ComponentTimelineEntry
76+
}
77+
78+
// ComponentTimelineEntry represents a point-in-time event collected for diagnostics.
79+
type ComponentTimelineEntry struct {
80+
Kind string
81+
Timestamp time.Time
82+
Duration time.Duration
6183
}
6284

6385
func NewHTMLComponent(name string, templateFs []byte, props map[string]any) *HTMLComponent {
@@ -98,10 +120,13 @@ func (c *HTMLComponent) Init(store *state.Store) {
98120
}
99121

100122
func (c *HTMLComponent) Render() (renderedTemplate string) {
123+
start := time.Now()
124+
defer c.recordRender(time.Since(start))
101125
key := c.cacheKey()
102126
if c.cache != nil {
103127
if val, ok := c.cache[key]; ok {
104-
return val
128+
renderedTemplate = val
129+
return
105130
}
106131
if c.lastCacheKey != "" && c.lastCacheKey != key {
107132
delete(c.cache, c.lastCacheKey)
@@ -187,6 +212,55 @@ func (c *HTMLComponent) Render() (renderedTemplate string) {
187212
return renderedTemplate
188213
}
189214

215+
const componentTimelineLimit = 64
216+
217+
func (c *HTMLComponent) recordRender(duration time.Duration) {
218+
if c == nil {
219+
return
220+
}
221+
c.metricsMu.Lock()
222+
c.renderCount++
223+
c.totalRender += duration
224+
c.lastRender = duration
225+
c.appendTimelineLocked(ComponentTimelineEntry{
226+
Kind: "render",
227+
Timestamp: time.Now(),
228+
Duration: duration,
229+
})
230+
c.metricsMu.Unlock()
231+
}
232+
233+
func (c *HTMLComponent) appendTimelineLocked(entry ComponentTimelineEntry) {
234+
if entry.Kind == "" {
235+
return
236+
}
237+
if c.timeline == nil {
238+
c.timeline = make([]ComponentTimelineEntry, 0, 8)
239+
}
240+
c.timeline = append(c.timeline, entry)
241+
if len(c.timeline) > componentTimelineLimit {
242+
c.timeline = append([]ComponentTimelineEntry(nil), c.timeline[len(c.timeline)-componentTimelineLimit:]...)
243+
}
244+
}
245+
246+
// Stats returns a snapshot of the component's render metrics.
247+
func (c *HTMLComponent) Stats() ComponentStats {
248+
c.metricsMu.Lock()
249+
defer c.metricsMu.Unlock()
250+
stats := ComponentStats{
251+
RenderCount: c.renderCount,
252+
TotalRender: c.totalRender,
253+
LastRender: c.lastRender,
254+
}
255+
if c.renderCount > 0 {
256+
stats.AverageRender = c.totalRender / time.Duration(c.renderCount)
257+
}
258+
if len(c.timeline) > 0 {
259+
stats.Timeline = append(stats.Timeline, c.timeline...)
260+
}
261+
return stats
262+
}
263+
190264
var (
191265
inlineMinifierOnce sync.Once
192266
inlineMinifier *minify.M
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build !js || !wasm
2+
3+
package core
4+
5+
import "time"
6+
7+
// ComponentStats is a stub for non-wasm builds.
8+
type ComponentStats struct {
9+
RenderCount int
10+
TotalRender time.Duration
11+
LastRender time.Duration
12+
AverageRender time.Duration
13+
Timeline []ComponentTimelineEntry
14+
}
15+
16+
// ComponentTimelineEntry is a stub for non-wasm builds.
17+
type ComponentTimelineEntry struct {
18+
Kind string
19+
Timestamp time.Time
20+
Duration time.Duration
21+
}
22+
23+
// Stats returns zeroed metrics on non-wasm builds.
24+
func (c *HTMLComponent) Stats() ComponentStats { return ComponentStats{} }

v1/devtools/devtools_js.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ var (
2424
func (plugin) Build(json.RawMessage) error { return nil }
2525
func (plugin) Install(a *core.App) {
2626
a.RegisterLifecycle(func(c core.Component) {
27+
appendLifecycle(c.GetID(), "mount", time.Now())
2728
if root == nil {
2829
root = c
2930
}
@@ -34,6 +35,7 @@ func (plugin) Install(a *core.App) {
3435
}
3536
}
3637
}, func(c core.Component) {
38+
appendLifecycle(c.GetID(), "unmount", time.Now())
3739
if root == c {
3840
resetTree()
3941
root = nil
@@ -43,6 +45,7 @@ func (plugin) Install(a *core.App) {
4345
fn.Invoke()
4446
}
4547
}
48+
dropLifecycle(c.GetID())
4649
})
4750
a.RegisterRouter(func(_ string) {
4851
if root != nil {

v1/devtools/timeline.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package devtools
2+
3+
import (
4+
"sort"
5+
"sync"
6+
"time"
7+
)
8+
9+
type lifecycleEvent struct {
10+
Kind string
11+
At time.Time
12+
}
13+
14+
const lifecycleLimit = 64
15+
16+
var (
17+
lifecycleMu sync.RWMutex
18+
lifecycleByComponent = map[string][]lifecycleEvent{}
19+
)
20+
21+
func appendLifecycle(id, kind string, at time.Time) {
22+
if id == "" || kind == "" {
23+
return
24+
}
25+
lifecycleMu.Lock()
26+
defer lifecycleMu.Unlock()
27+
entry := lifecycleEvent{Kind: kind, At: at}
28+
list := append(lifecycleByComponent[id], entry)
29+
if len(list) > lifecycleLimit {
30+
list = append([]lifecycleEvent(nil), list[len(list)-lifecycleLimit:]...)
31+
}
32+
lifecycleByComponent[id] = list
33+
}
34+
35+
func dropLifecycle(id string) {
36+
if id == "" {
37+
return
38+
}
39+
lifecycleMu.Lock()
40+
delete(lifecycleByComponent, id)
41+
lifecycleMu.Unlock()
42+
}
43+
44+
func snapshotLifecycle(id string) []lifecycleEvent {
45+
lifecycleMu.RLock()
46+
list := lifecycleByComponent[id]
47+
lifecycleMu.RUnlock()
48+
if len(list) == 0 {
49+
return nil
50+
}
51+
out := make([]lifecycleEvent, len(list))
52+
copy(out, list)
53+
sort.Slice(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
54+
return out
55+
}
56+
57+
func resetLifecycles() {
58+
lifecycleMu.Lock()
59+
lifecycleByComponent = map[string][]lifecycleEvent{}
60+
lifecycleMu.Unlock()
61+
}

0 commit comments

Comments
 (0)