preact-perf-tracker 0.1.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/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/index.cjs +744 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +112 -0
- package/dist/index.d.mts +112 -0
- package/dist/index.mjs +737 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +61 -0
- package/src/__tests__/index.test.ts +269 -0
- package/src/__tests__/instrumentation.test.ts +490 -0
- package/src/__tests__/utils.test.ts +167 -0
- package/src/index.ts +130 -0
- package/src/instrumentation.ts +412 -0
- package/src/overlay.ts +210 -0
- package/src/toolbar.ts +230 -0
- package/src/types.ts +147 -0
- package/src/utils.ts +62 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
let preact = require("preact");
|
|
3
|
+
|
|
4
|
+
//#region src/types.ts
|
|
5
|
+
let ChangeType = /* @__PURE__ */ function(ChangeType) {
|
|
6
|
+
ChangeType[ChangeType["Props"] = 1] = "Props";
|
|
7
|
+
ChangeType[ChangeType["State"] = 2] = "State";
|
|
8
|
+
ChangeType[ChangeType["Context"] = 4] = "Context";
|
|
9
|
+
ChangeType[ChangeType["Force"] = 8] = "Force";
|
|
10
|
+
return ChangeType;
|
|
11
|
+
}({});
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/utils.ts
|
|
15
|
+
function getDisplayName(vnode) {
|
|
16
|
+
const type = vnode.type;
|
|
17
|
+
if (typeof type === "string") return null;
|
|
18
|
+
if (typeof type === "function") {
|
|
19
|
+
const name = type.displayName || type.name || null;
|
|
20
|
+
if (name === "type" || name === "anonymous") return null;
|
|
21
|
+
return name;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
function getComponentDOMNode(vnode) {
|
|
26
|
+
let dom = vnode.__e;
|
|
27
|
+
if (dom instanceof Element) return dom;
|
|
28
|
+
const children = vnode.__k;
|
|
29
|
+
if (children) for (let i = 0; i < children.length; i++) {
|
|
30
|
+
const child = children[i];
|
|
31
|
+
if (!child) continue;
|
|
32
|
+
if (child.__e instanceof Element) return child.__e;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function shallowDiff(prev, next) {
|
|
37
|
+
const changed = [];
|
|
38
|
+
if (!prev && !next) return changed;
|
|
39
|
+
if (!prev || !next) return Object.keys(next || prev || {});
|
|
40
|
+
const allKeys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
|
41
|
+
for (const key of allKeys) {
|
|
42
|
+
if (key === "children") continue;
|
|
43
|
+
if (!Object.is(prev[key], next[key])) changed.push(key);
|
|
44
|
+
}
|
|
45
|
+
return changed;
|
|
46
|
+
}
|
|
47
|
+
function isComponentVNode(vnode) {
|
|
48
|
+
return typeof vnode.type === "function";
|
|
49
|
+
}
|
|
50
|
+
function snapshot(obj) {
|
|
51
|
+
if (!obj) return null;
|
|
52
|
+
return Object.assign({}, obj);
|
|
53
|
+
}
|
|
54
|
+
const now = typeof performance !== "undefined" ? () => performance.now() : () => Date.now();
|
|
55
|
+
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/instrumentation.ts
|
|
58
|
+
const renderStartTimes = /* @__PURE__ */ new WeakMap();
|
|
59
|
+
const defaultOptions = {
|
|
60
|
+
enabled: true,
|
|
61
|
+
log: false,
|
|
62
|
+
showToolbar: true,
|
|
63
|
+
animationSpeed: "fast"
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Whether our permanent hook wrappers have been installed.
|
|
67
|
+
* Once installed they stay in place for the lifetime of the page;
|
|
68
|
+
* `isHooked` toggles them between active / pass-through so that
|
|
69
|
+
* libraries hooking *after* us are never clobbered on unhook.
|
|
70
|
+
*/
|
|
71
|
+
let hooksInstalled = false;
|
|
72
|
+
let savedOriginalHooks = null;
|
|
73
|
+
let activeOptions = { ...defaultOptions };
|
|
74
|
+
const reportData = /* @__PURE__ */ new Map();
|
|
75
|
+
const renderListeners = /* @__PURE__ */ new Set();
|
|
76
|
+
let isHooked = false;
|
|
77
|
+
let inCommit = false;
|
|
78
|
+
function detectChanges(component, vnode, isMounting) {
|
|
79
|
+
const changes = [];
|
|
80
|
+
if (isMounting) return changes;
|
|
81
|
+
const prevProps = component.__prevProps;
|
|
82
|
+
const nextProps = vnode.props;
|
|
83
|
+
const changedPropKeys = shallowDiff(prevProps, nextProps);
|
|
84
|
+
for (const key of changedPropKeys) changes.push({
|
|
85
|
+
type: ChangeType.Props,
|
|
86
|
+
name: key,
|
|
87
|
+
prevValue: prevProps?.[key],
|
|
88
|
+
nextValue: nextProps[key]
|
|
89
|
+
});
|
|
90
|
+
const prevState = component.__prevState;
|
|
91
|
+
const nextState = component.__s ?? component.state;
|
|
92
|
+
if (prevState && nextState) {
|
|
93
|
+
const changedStateKeys = shallowDiff(prevState, nextState);
|
|
94
|
+
for (const key of changedStateKeys) changes.push({
|
|
95
|
+
type: ChangeType.State,
|
|
96
|
+
name: key,
|
|
97
|
+
prevValue: prevState[key],
|
|
98
|
+
nextValue: nextState[key]
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (component.__f) changes.push({
|
|
102
|
+
type: ChangeType.Force,
|
|
103
|
+
name: "forceUpdate"
|
|
104
|
+
});
|
|
105
|
+
return changes;
|
|
106
|
+
}
|
|
107
|
+
function emitRender(info) {
|
|
108
|
+
for (const listener of renderListeners) listener(info);
|
|
109
|
+
}
|
|
110
|
+
function onBeforeDiff(vnode) {
|
|
111
|
+
if (!activeOptions.enabled) return;
|
|
112
|
+
if (!inCommit) {
|
|
113
|
+
inCommit = true;
|
|
114
|
+
activeOptions.onCommitStart?.();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function onBeforeRender(vnode) {
|
|
118
|
+
if (!activeOptions.enabled) return;
|
|
119
|
+
if (!isComponentVNode(vnode)) return;
|
|
120
|
+
if (vnode.type === preact.Fragment) return;
|
|
121
|
+
const component = vnode.__c;
|
|
122
|
+
if (!component) return;
|
|
123
|
+
renderStartTimes.set(component, now());
|
|
124
|
+
}
|
|
125
|
+
function onDiffed(vnode) {
|
|
126
|
+
if (!activeOptions.enabled) return;
|
|
127
|
+
if (!isComponentVNode(vnode)) return;
|
|
128
|
+
if (vnode.type === preact.Fragment) return;
|
|
129
|
+
const component = vnode.__c;
|
|
130
|
+
if (!component) return;
|
|
131
|
+
const startTime = renderStartTimes.get(component);
|
|
132
|
+
const selfTime = startTime != null ? now() - startTime : 0;
|
|
133
|
+
renderStartTimes.delete(component);
|
|
134
|
+
const isMounting = component.__prevProps === void 0;
|
|
135
|
+
const changes = detectChanges(component, vnode, isMounting);
|
|
136
|
+
const componentName = getDisplayName(vnode) || "Anonymous";
|
|
137
|
+
const domNode = getComponentDOMNode(vnode);
|
|
138
|
+
const info = {
|
|
139
|
+
componentName,
|
|
140
|
+
phase: isMounting ? "mount" : "update",
|
|
141
|
+
selfTime,
|
|
142
|
+
changes,
|
|
143
|
+
timestamp: now(),
|
|
144
|
+
domNode
|
|
145
|
+
};
|
|
146
|
+
component.__prevProps = snapshot(vnode.props);
|
|
147
|
+
component.__prevState = snapshot(component.__s ?? component.state);
|
|
148
|
+
const type = vnode.type;
|
|
149
|
+
let entry = reportData.get(type);
|
|
150
|
+
if (!entry) {
|
|
151
|
+
entry = {
|
|
152
|
+
count: 0,
|
|
153
|
+
totalSelfTime: 0,
|
|
154
|
+
displayName: componentName,
|
|
155
|
+
type
|
|
156
|
+
};
|
|
157
|
+
reportData.set(type, entry);
|
|
158
|
+
}
|
|
159
|
+
entry.count++;
|
|
160
|
+
entry.totalSelfTime += selfTime;
|
|
161
|
+
if (activeOptions.log) logRender(info);
|
|
162
|
+
activeOptions.onRender?.(info);
|
|
163
|
+
emitRender(info);
|
|
164
|
+
}
|
|
165
|
+
function onCommit(_vnode, _commitQueue) {
|
|
166
|
+
if (!activeOptions.enabled) return;
|
|
167
|
+
if (inCommit) {
|
|
168
|
+
inCommit = false;
|
|
169
|
+
activeOptions.onCommitFinish?.();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function onUnmount(vnode) {
|
|
173
|
+
if (!activeOptions.enabled) return;
|
|
174
|
+
if (!isComponentVNode(vnode)) return;
|
|
175
|
+
if (vnode.type === preact.Fragment) return;
|
|
176
|
+
const componentName = getDisplayName(vnode) || "Anonymous";
|
|
177
|
+
const domNode = getComponentDOMNode(vnode);
|
|
178
|
+
const info = {
|
|
179
|
+
componentName,
|
|
180
|
+
phase: "unmount",
|
|
181
|
+
selfTime: 0,
|
|
182
|
+
changes: [],
|
|
183
|
+
timestamp: now(),
|
|
184
|
+
domNode
|
|
185
|
+
};
|
|
186
|
+
activeOptions.onRender?.(info);
|
|
187
|
+
emitRender(info);
|
|
188
|
+
}
|
|
189
|
+
function logRender(info) {
|
|
190
|
+
const parts = [
|
|
191
|
+
`%c[preact-perf-tracker]%c ${info.componentName}`,
|
|
192
|
+
"color: #8b5cf6; font-weight: bold",
|
|
193
|
+
"color: inherit"
|
|
194
|
+
];
|
|
195
|
+
const meta = [info.phase];
|
|
196
|
+
if (info.selfTime >= .01) meta.push(`${info.selfTime.toFixed(2)}ms`);
|
|
197
|
+
if (info.changes.length > 0) meta.push(info.changes.map((c) => `${c.type === 1 ? "prop" : c.type === 2 ? "state" : c.type === 4 ? "ctx" : "force"}:${c.name}`).join(", "));
|
|
198
|
+
parts[0] += ` (${meta.join(" · ")})`;
|
|
199
|
+
console.log(...parts);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Install the Preact options hooks for render tracking.
|
|
203
|
+
*
|
|
204
|
+
* The wrappers are installed once and stay in place for the lifetime
|
|
205
|
+
* of the page. `isHooked` toggles them between "active" (running
|
|
206
|
+
* our instrumentation) and "pass-through" (just forwarding to the
|
|
207
|
+
* previously chained hook). This avoids the destructive restore
|
|
208
|
+
* that would clobber hooks installed by other libraries (e.g.
|
|
209
|
+
* hooks / signals) that chain after us.
|
|
210
|
+
*/
|
|
211
|
+
function hookIntoPreact() {
|
|
212
|
+
if (isHooked) return;
|
|
213
|
+
isHooked = true;
|
|
214
|
+
if (hooksInstalled) return;
|
|
215
|
+
hooksInstalled = true;
|
|
216
|
+
const opts = preact.options;
|
|
217
|
+
savedOriginalHooks = {
|
|
218
|
+
__b: opts.__b,
|
|
219
|
+
__r: opts.__r,
|
|
220
|
+
diffed: opts.diffed,
|
|
221
|
+
__c: opts.__c,
|
|
222
|
+
unmount: opts.unmount
|
|
223
|
+
};
|
|
224
|
+
const prev__b = opts.__b;
|
|
225
|
+
opts.__b = (vnode) => {
|
|
226
|
+
if (isHooked) onBeforeDiff(vnode);
|
|
227
|
+
prev__b?.(vnode);
|
|
228
|
+
};
|
|
229
|
+
const prev__r = opts.__r;
|
|
230
|
+
opts.__r = (vnode) => {
|
|
231
|
+
if (isHooked) onBeforeRender(vnode);
|
|
232
|
+
prev__r?.(vnode);
|
|
233
|
+
};
|
|
234
|
+
const prevDiffed = opts.diffed;
|
|
235
|
+
opts.diffed = (vnode) => {
|
|
236
|
+
if (isHooked) onDiffed(vnode);
|
|
237
|
+
prevDiffed?.(vnode);
|
|
238
|
+
};
|
|
239
|
+
const prev__c = opts.__c;
|
|
240
|
+
opts.__c = (vnode, queue) => {
|
|
241
|
+
if (isHooked) onCommit(vnode, queue);
|
|
242
|
+
prev__c?.(vnode, queue);
|
|
243
|
+
};
|
|
244
|
+
const prevUnmount = opts.unmount;
|
|
245
|
+
opts.unmount = (vnode) => {
|
|
246
|
+
if (isHooked) onUnmount(vnode);
|
|
247
|
+
prevUnmount?.(vnode);
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Disable our instrumentation hooks. The wrappers stay in the
|
|
252
|
+
* options chain as transparent pass-throughs so that hooks installed
|
|
253
|
+
* by other libraries after us are never lost.
|
|
254
|
+
*/
|
|
255
|
+
function unhookFromPreact() {
|
|
256
|
+
isHooked = false;
|
|
257
|
+
}
|
|
258
|
+
function getActiveOptions() {
|
|
259
|
+
return activeOptions;
|
|
260
|
+
}
|
|
261
|
+
function setActiveOptions(opts) {
|
|
262
|
+
Object.assign(activeOptions, opts);
|
|
263
|
+
}
|
|
264
|
+
function resetActiveOptions() {
|
|
265
|
+
activeOptions = { ...defaultOptions };
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Get render report for all tracked components, or a specific type.
|
|
269
|
+
*/
|
|
270
|
+
function getReport$1(type) {
|
|
271
|
+
if (type !== void 0) return reportData.get(type) ?? null;
|
|
272
|
+
return new Map(reportData);
|
|
273
|
+
}
|
|
274
|
+
function clearReport$1() {
|
|
275
|
+
reportData.clear();
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Register a listener that fires for every tracked render.
|
|
279
|
+
*/
|
|
280
|
+
function addRenderListener(fn) {
|
|
281
|
+
renderListeners.add(fn);
|
|
282
|
+
}
|
|
283
|
+
function removeRenderListener(fn) {
|
|
284
|
+
renderListeners.delete(fn);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Get a sorted summary for quick insights (highest total self-time first).
|
|
288
|
+
*/
|
|
289
|
+
function getReportSummary$1(limit = 10) {
|
|
290
|
+
const normalizedLimit = Math.max(1, Math.floor(limit));
|
|
291
|
+
return Array.from(reportData.values()).map((entry) => ({
|
|
292
|
+
displayName: entry.displayName || "Anonymous",
|
|
293
|
+
count: entry.count,
|
|
294
|
+
totalSelfTime: entry.totalSelfTime,
|
|
295
|
+
avgSelfTime: entry.count > 0 ? entry.totalSelfTime / entry.count : 0
|
|
296
|
+
})).sort((a, b) => {
|
|
297
|
+
if (b.totalSelfTime !== a.totalSelfTime) return b.totalSelfTime - a.totalSelfTime;
|
|
298
|
+
return b.count - a.count;
|
|
299
|
+
}).slice(0, normalizedLimit);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/overlay.ts
|
|
304
|
+
const OUTLINE_DURATION_MS = 750;
|
|
305
|
+
const FADE_SPEED = {
|
|
306
|
+
fast: 1.5,
|
|
307
|
+
slow: .6,
|
|
308
|
+
off: 999
|
|
309
|
+
};
|
|
310
|
+
function getOutlineColor(count) {
|
|
311
|
+
if (count <= 1) return "rgba(128, 90, 213, ALPHA)";
|
|
312
|
+
if (count <= 4) return "rgba(168, 85, 247, ALPHA)";
|
|
313
|
+
if (count <= 10) return "rgba(234, 88, 12, ALPHA)";
|
|
314
|
+
return "rgba(239, 68, 68, ALPHA)";
|
|
315
|
+
}
|
|
316
|
+
function borderWidthForTime(ms) {
|
|
317
|
+
if (ms < 1) return 1;
|
|
318
|
+
if (ms < 8) return 2;
|
|
319
|
+
if (ms < 16) return 3;
|
|
320
|
+
return 4;
|
|
321
|
+
}
|
|
322
|
+
const outlines = /* @__PURE__ */ new Map();
|
|
323
|
+
let canvas = null;
|
|
324
|
+
let ctx = null;
|
|
325
|
+
let rafId = null;
|
|
326
|
+
let isRunning = false;
|
|
327
|
+
function ensureCanvas() {
|
|
328
|
+
if (canvas && ctx) return ctx;
|
|
329
|
+
canvas = document.createElement("canvas");
|
|
330
|
+
canvas.id = "preact-scan-overlay";
|
|
331
|
+
Object.assign(canvas.style, {
|
|
332
|
+
position: "fixed",
|
|
333
|
+
top: "0",
|
|
334
|
+
left: "0",
|
|
335
|
+
width: "100vw",
|
|
336
|
+
height: "100vh",
|
|
337
|
+
pointerEvents: "none",
|
|
338
|
+
zIndex: "2147483646"
|
|
339
|
+
});
|
|
340
|
+
document.documentElement.appendChild(canvas);
|
|
341
|
+
ctx = canvas.getContext("2d");
|
|
342
|
+
resizeCanvas();
|
|
343
|
+
window.addEventListener("resize", resizeCanvas);
|
|
344
|
+
return ctx;
|
|
345
|
+
}
|
|
346
|
+
function resizeCanvas() {
|
|
347
|
+
if (!canvas) return;
|
|
348
|
+
const dpr = window.devicePixelRatio || 1;
|
|
349
|
+
canvas.width = window.innerWidth * dpr;
|
|
350
|
+
canvas.height = window.innerHeight * dpr;
|
|
351
|
+
ctx?.scale(dpr, dpr);
|
|
352
|
+
}
|
|
353
|
+
function drawFrame() {
|
|
354
|
+
if (!ctx || !canvas) return;
|
|
355
|
+
const speed = FADE_SPEED[getActiveOptions().animationSpeed ?? "fast"] ?? 1.5;
|
|
356
|
+
const nowMs = performance.now();
|
|
357
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
358
|
+
for (const [element, outline] of outlines) {
|
|
359
|
+
if (!element.isConnected) {
|
|
360
|
+
outlines.delete(element);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const elapsed = nowMs - outline.timestamp;
|
|
364
|
+
const progress = Math.min(elapsed / OUTLINE_DURATION_MS, 1);
|
|
365
|
+
outline.alpha = Math.max(0, 1 - progress * speed);
|
|
366
|
+
if (outline.alpha <= .01) {
|
|
367
|
+
outlines.delete(element);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const rect = element.getBoundingClientRect();
|
|
371
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
372
|
+
outline.rect = rect;
|
|
373
|
+
drawOutline(outline);
|
|
374
|
+
}
|
|
375
|
+
if (outlines.size > 0) rafId = requestAnimationFrame(drawFrame);
|
|
376
|
+
else {
|
|
377
|
+
stopLoop();
|
|
378
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function drawOutline(outline) {
|
|
382
|
+
if (!ctx) return;
|
|
383
|
+
const { rect, alpha, color, count, name } = outline;
|
|
384
|
+
const resolvedColor = color.replace("ALPHA", String(alpha));
|
|
385
|
+
const borderWidth = borderWidthForTime(outline.selfTimeMs);
|
|
386
|
+
ctx.strokeStyle = resolvedColor;
|
|
387
|
+
ctx.lineWidth = borderWidth;
|
|
388
|
+
ctx.strokeRect(rect.x + borderWidth / 2, rect.y + borderWidth / 2, rect.width - borderWidth, rect.height - borderWidth);
|
|
389
|
+
ctx.fillStyle = color.replace("ALPHA", String(alpha * .08));
|
|
390
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
391
|
+
if (alpha > .3) {
|
|
392
|
+
const renderTimeLabel = outline.selfTimeMs >= .01 ? ` ${outline.selfTimeMs.toFixed(2)}ms` : "";
|
|
393
|
+
const label = count > 1 ? `${name} ×${count}${renderTimeLabel}` : `${name}${renderTimeLabel}`;
|
|
394
|
+
const fontSize = 10;
|
|
395
|
+
ctx.font = `600 ${fontSize}px ui-monospace, SFMono-Regular, Menlo, monospace`;
|
|
396
|
+
const textMetrics = ctx.measureText(label);
|
|
397
|
+
const padding = 3;
|
|
398
|
+
const labelHeight = fontSize + padding * 2;
|
|
399
|
+
const labelWidth = textMetrics.width + padding * 2;
|
|
400
|
+
let labelX = rect.x;
|
|
401
|
+
let labelY = rect.y - labelHeight - 1;
|
|
402
|
+
if (labelY < 0) labelY = rect.y + rect.height + 1;
|
|
403
|
+
ctx.fillStyle = color.replace("ALPHA", String(Math.min(alpha, .9)));
|
|
404
|
+
ctx.fillRect(labelX, labelY, labelWidth, labelHeight);
|
|
405
|
+
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
|
|
406
|
+
ctx.fillText(label, labelX + padding, labelY + fontSize + padding - 1);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function onRender(info) {
|
|
410
|
+
if (info.phase === "unmount") {
|
|
411
|
+
if (info.domNode) outlines.delete(info.domNode);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const el = info.domNode;
|
|
415
|
+
if (!el) return;
|
|
416
|
+
const existing = outlines.get(el);
|
|
417
|
+
const count = existing ? existing.count + 1 : 1;
|
|
418
|
+
outlines.set(el, {
|
|
419
|
+
rect: el.getBoundingClientRect(),
|
|
420
|
+
alpha: 1,
|
|
421
|
+
color: getOutlineColor(count),
|
|
422
|
+
count,
|
|
423
|
+
name: info.componentName,
|
|
424
|
+
selfTimeMs: info.selfTime,
|
|
425
|
+
timestamp: performance.now()
|
|
426
|
+
});
|
|
427
|
+
ensureLoop();
|
|
428
|
+
}
|
|
429
|
+
function ensureLoop() {
|
|
430
|
+
if (isRunning) return;
|
|
431
|
+
isRunning = true;
|
|
432
|
+
ensureCanvas();
|
|
433
|
+
rafId = requestAnimationFrame(drawFrame);
|
|
434
|
+
}
|
|
435
|
+
function stopLoop() {
|
|
436
|
+
isRunning = false;
|
|
437
|
+
if (rafId != null) {
|
|
438
|
+
cancelAnimationFrame(rafId);
|
|
439
|
+
rafId = null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function startOverlay() {
|
|
443
|
+
addRenderListener(onRender);
|
|
444
|
+
}
|
|
445
|
+
function stopOverlay() {
|
|
446
|
+
removeRenderListener(onRender);
|
|
447
|
+
stopLoop();
|
|
448
|
+
outlines.clear();
|
|
449
|
+
if (canvas) {
|
|
450
|
+
canvas.remove();
|
|
451
|
+
canvas = null;
|
|
452
|
+
ctx = null;
|
|
453
|
+
}
|
|
454
|
+
window.removeEventListener("resize", resizeCanvas);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
//#endregion
|
|
458
|
+
//#region src/toolbar.ts
|
|
459
|
+
let rootContainer = null;
|
|
460
|
+
let shadowRoot = null;
|
|
461
|
+
let renderCount = 0;
|
|
462
|
+
let fps = 0;
|
|
463
|
+
let rendersPerSecond = 0;
|
|
464
|
+
let frameCount = 0;
|
|
465
|
+
let lastFpsTime = performance.now();
|
|
466
|
+
let fpsRafId = null;
|
|
467
|
+
let rendersThisSecond = 0;
|
|
468
|
+
function updateFps() {
|
|
469
|
+
frameCount++;
|
|
470
|
+
const now = performance.now();
|
|
471
|
+
if (now - lastFpsTime >= 1e3) {
|
|
472
|
+
fps = frameCount;
|
|
473
|
+
rendersPerSecond = rendersThisSecond;
|
|
474
|
+
rendersThisSecond = 0;
|
|
475
|
+
frameCount = 0;
|
|
476
|
+
lastFpsTime = now;
|
|
477
|
+
updateDisplay();
|
|
478
|
+
}
|
|
479
|
+
fpsRafId = requestAnimationFrame(updateFps);
|
|
480
|
+
}
|
|
481
|
+
function notifyToolbarRender() {
|
|
482
|
+
renderCount++;
|
|
483
|
+
rendersThisSecond++;
|
|
484
|
+
updateDisplay();
|
|
485
|
+
}
|
|
486
|
+
const TOOLBAR_STYLES = `
|
|
487
|
+
:host {
|
|
488
|
+
all: initial;
|
|
489
|
+
}
|
|
490
|
+
.toolbar {
|
|
491
|
+
position: fixed;
|
|
492
|
+
bottom: 12px;
|
|
493
|
+
left: 50%;
|
|
494
|
+
transform: translateX(-50%);
|
|
495
|
+
z-index: 2147483647;
|
|
496
|
+
display: flex;
|
|
497
|
+
align-items: center;
|
|
498
|
+
gap: 8px;
|
|
499
|
+
padding: 6px 12px;
|
|
500
|
+
background: #0a0a0a;
|
|
501
|
+
border: 1px solid #27272a;
|
|
502
|
+
border-radius: 10px;
|
|
503
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
504
|
+
font-size: 12px;
|
|
505
|
+
color: #e4e4e7;
|
|
506
|
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
|
507
|
+
user-select: none;
|
|
508
|
+
cursor: default;
|
|
509
|
+
line-height: 1;
|
|
510
|
+
}
|
|
511
|
+
.toolbar-title {
|
|
512
|
+
font-weight: 700;
|
|
513
|
+
color: #a78bfa;
|
|
514
|
+
padding-right: 4px;
|
|
515
|
+
display: flex;
|
|
516
|
+
align-items: center;
|
|
517
|
+
gap: 4px;
|
|
518
|
+
}
|
|
519
|
+
.toolbar-title svg {
|
|
520
|
+
width: 14px;
|
|
521
|
+
height: 14px;
|
|
522
|
+
}
|
|
523
|
+
.stat {
|
|
524
|
+
color: #a1a1aa;
|
|
525
|
+
padding: 0 4px;
|
|
526
|
+
}
|
|
527
|
+
.stat-value {
|
|
528
|
+
color: #e4e4e7;
|
|
529
|
+
font-weight: 600;
|
|
530
|
+
}
|
|
531
|
+
.separator {
|
|
532
|
+
width: 1px;
|
|
533
|
+
height: 14px;
|
|
534
|
+
background: #27272a;
|
|
535
|
+
}
|
|
536
|
+
.toggle-btn {
|
|
537
|
+
background: none;
|
|
538
|
+
border: 1px solid #3f3f46;
|
|
539
|
+
border-radius: 6px;
|
|
540
|
+
color: #e4e4e7;
|
|
541
|
+
padding: 3px 8px;
|
|
542
|
+
font-size: 11px;
|
|
543
|
+
font-family: inherit;
|
|
544
|
+
cursor: pointer;
|
|
545
|
+
transition: background 0.15s, border-color 0.15s;
|
|
546
|
+
}
|
|
547
|
+
.toggle-btn:hover {
|
|
548
|
+
background: #27272a;
|
|
549
|
+
border-color: #52525b;
|
|
550
|
+
}
|
|
551
|
+
.toggle-btn.active {
|
|
552
|
+
background: #7c3aed;
|
|
553
|
+
border-color: #8b5cf6;
|
|
554
|
+
}
|
|
555
|
+
`;
|
|
556
|
+
function createToolbarDOM() {
|
|
557
|
+
rootContainer = document.createElement("div");
|
|
558
|
+
rootContainer.id = "preact-tracker-toolbar";
|
|
559
|
+
shadowRoot = rootContainer.attachShadow({ mode: "open" });
|
|
560
|
+
const style = document.createElement("style");
|
|
561
|
+
style.textContent = TOOLBAR_STYLES;
|
|
562
|
+
shadowRoot.appendChild(style);
|
|
563
|
+
const toolbar = document.createElement("div");
|
|
564
|
+
toolbar.className = "toolbar";
|
|
565
|
+
toolbar.innerHTML = `
|
|
566
|
+
<span class="toolbar-title">
|
|
567
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
568
|
+
<circle cx="12" cy="12" r="10"/>
|
|
569
|
+
<line x1="12" y1="8" x2="12" y2="12"/>
|
|
570
|
+
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
571
|
+
</svg>
|
|
572
|
+
preact-perf-tracker
|
|
573
|
+
</span>
|
|
574
|
+
<span class="separator"></span>
|
|
575
|
+
<span class="stat">renders: <span class="stat-value" data-renders>0</span></span>
|
|
576
|
+
<span class="separator"></span>
|
|
577
|
+
<span class="stat">r/s: <span class="stat-value" data-rps>0</span></span>
|
|
578
|
+
<span class="separator"></span>
|
|
579
|
+
<span class="stat">fps: <span class="stat-value" data-fps>--</span></span>
|
|
580
|
+
<span class="separator"></span>
|
|
581
|
+
<span class="stat">hot: <span class="stat-value" data-hot>--</span></span>
|
|
582
|
+
<span class="separator"></span>
|
|
583
|
+
<button class="toggle-btn active" data-toggle>Enabled</button>
|
|
584
|
+
<button class="toggle-btn" data-reset>Reset</button>
|
|
585
|
+
<button class="toggle-btn" data-copy>Copy</button>
|
|
586
|
+
`;
|
|
587
|
+
const toggleBtn = toolbar.querySelector("[data-toggle]");
|
|
588
|
+
toggleBtn.addEventListener("click", () => {
|
|
589
|
+
const next = !getActiveOptions().enabled;
|
|
590
|
+
setActiveOptions({ enabled: next });
|
|
591
|
+
toggleBtn.textContent = next ? "Enabled" : "Disabled";
|
|
592
|
+
toggleBtn.classList.toggle("active", next);
|
|
593
|
+
});
|
|
594
|
+
toolbar.querySelector("[data-reset]").addEventListener("click", () => {
|
|
595
|
+
clearReport$1();
|
|
596
|
+
renderCount = 0;
|
|
597
|
+
rendersThisSecond = 0;
|
|
598
|
+
rendersPerSecond = 0;
|
|
599
|
+
updateDisplay();
|
|
600
|
+
});
|
|
601
|
+
const copyBtn = toolbar.querySelector("[data-copy]");
|
|
602
|
+
copyBtn.addEventListener("click", async () => {
|
|
603
|
+
const summary = getReportSummary$1(25);
|
|
604
|
+
const payload = JSON.stringify(summary, null, 2);
|
|
605
|
+
if (!navigator.clipboard?.writeText) return;
|
|
606
|
+
await navigator.clipboard.writeText(payload);
|
|
607
|
+
const old = copyBtn.textContent;
|
|
608
|
+
copyBtn.textContent = "Copied";
|
|
609
|
+
window.setTimeout(() => {
|
|
610
|
+
copyBtn.textContent = old;
|
|
611
|
+
}, 1200);
|
|
612
|
+
});
|
|
613
|
+
shadowRoot.appendChild(toolbar);
|
|
614
|
+
const legacyMarker = document.createElement("span");
|
|
615
|
+
legacyMarker.id = "preact-scan-toolbar";
|
|
616
|
+
legacyMarker.style.display = "none";
|
|
617
|
+
shadowRoot.appendChild(legacyMarker);
|
|
618
|
+
document.documentElement.appendChild(rootContainer);
|
|
619
|
+
return shadowRoot;
|
|
620
|
+
}
|
|
621
|
+
function updateDisplay() {
|
|
622
|
+
if (!shadowRoot) return;
|
|
623
|
+
const rendersEl = shadowRoot.querySelector("[data-renders]");
|
|
624
|
+
const rpsEl = shadowRoot.querySelector("[data-rps]");
|
|
625
|
+
const fpsEl = shadowRoot.querySelector("[data-fps]");
|
|
626
|
+
const hotEl = shadowRoot.querySelector("[data-hot]");
|
|
627
|
+
const hottest = getReportSummary$1(1)[0];
|
|
628
|
+
const hotLabel = hottest ? `${hottest.displayName} (${hottest.totalSelfTime.toFixed(1)}ms)` : "--";
|
|
629
|
+
if (rendersEl) rendersEl.textContent = String(renderCount);
|
|
630
|
+
if (rpsEl) rpsEl.textContent = String(rendersPerSecond);
|
|
631
|
+
if (fpsEl) fpsEl.textContent = String(fps);
|
|
632
|
+
if (hotEl) hotEl.textContent = hotLabel;
|
|
633
|
+
}
|
|
634
|
+
function createToolbar() {
|
|
635
|
+
if (rootContainer) return;
|
|
636
|
+
createToolbarDOM();
|
|
637
|
+
fpsRafId = requestAnimationFrame(updateFps);
|
|
638
|
+
}
|
|
639
|
+
function destroyToolbar() {
|
|
640
|
+
if (fpsRafId != null) {
|
|
641
|
+
cancelAnimationFrame(fpsRafId);
|
|
642
|
+
fpsRafId = null;
|
|
643
|
+
}
|
|
644
|
+
if (rootContainer) {
|
|
645
|
+
rootContainer.remove();
|
|
646
|
+
rootContainer = null;
|
|
647
|
+
shadowRoot = null;
|
|
648
|
+
}
|
|
649
|
+
renderCount = 0;
|
|
650
|
+
rendersThisSecond = 0;
|
|
651
|
+
rendersPerSecond = 0;
|
|
652
|
+
fps = 0;
|
|
653
|
+
frameCount = 0;
|
|
654
|
+
lastFpsTime = performance.now();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/index.ts
|
|
659
|
+
let started = false;
|
|
660
|
+
const toolbarRenderListener = (_info) => {
|
|
661
|
+
notifyToolbarRender();
|
|
662
|
+
};
|
|
663
|
+
/**
|
|
664
|
+
* Start tracking component renders.
|
|
665
|
+
*
|
|
666
|
+
* ```ts
|
|
667
|
+
* import { install } from 'preact-perf-tracker';
|
|
668
|
+
*
|
|
669
|
+
* install({
|
|
670
|
+
* enabled: true,
|
|
671
|
+
* log: false,
|
|
672
|
+
* showToolbar: true,
|
|
673
|
+
* });
|
|
674
|
+
* ```
|
|
675
|
+
*/
|
|
676
|
+
function install(options = {}) {
|
|
677
|
+
resetActiveOptions();
|
|
678
|
+
setOptions(options);
|
|
679
|
+
const opts = getActiveOptions();
|
|
680
|
+
if (opts.enabled === false && opts.showToolbar !== true) return;
|
|
681
|
+
start();
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Update options at runtime.
|
|
685
|
+
*/
|
|
686
|
+
function setOptions(options) {
|
|
687
|
+
setActiveOptions(options);
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Get the current options.
|
|
691
|
+
*/
|
|
692
|
+
function getOptions() {
|
|
693
|
+
return getActiveOptions();
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Get a report of all tracked components, or a single component type.
|
|
697
|
+
*/
|
|
698
|
+
function getReport(type) {
|
|
699
|
+
return getReport$1(type);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Get the top report entries sorted by total self-time.
|
|
703
|
+
*/
|
|
704
|
+
function getReportSummary(limit = 10) {
|
|
705
|
+
return getReportSummary$1(limit);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Clear all accumulated report data.
|
|
709
|
+
*/
|
|
710
|
+
function clearReport() {
|
|
711
|
+
clearReport$1();
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Stop tracking and clean up all resources.
|
|
715
|
+
*/
|
|
716
|
+
function stop() {
|
|
717
|
+
if (!started) return;
|
|
718
|
+
started = false;
|
|
719
|
+
removeRenderListener(toolbarRenderListener);
|
|
720
|
+
unhookFromPreact();
|
|
721
|
+
stopOverlay();
|
|
722
|
+
destroyToolbar();
|
|
723
|
+
}
|
|
724
|
+
function start() {
|
|
725
|
+
if (started) return;
|
|
726
|
+
started = true;
|
|
727
|
+
hookIntoPreact();
|
|
728
|
+
addRenderListener(toolbarRenderListener);
|
|
729
|
+
startOverlay();
|
|
730
|
+
if (getActiveOptions().showToolbar !== false) {
|
|
731
|
+
if (typeof document !== "undefined") if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => createToolbar(), { once: true });
|
|
732
|
+
else createToolbar();
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
//#endregion
|
|
737
|
+
exports.clearReport = clearReport;
|
|
738
|
+
exports.getOptions = getOptions;
|
|
739
|
+
exports.getReport = getReport;
|
|
740
|
+
exports.getReportSummary = getReportSummary;
|
|
741
|
+
exports.install = install;
|
|
742
|
+
exports.setOptions = setOptions;
|
|
743
|
+
exports.stop = stop;
|
|
744
|
+
//# sourceMappingURL=index.cjs.map
|