catalyst-core 0.2.0-beta.1 → 0.2.0-beta.3
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/dist/native/androidProject/app/build.gradle.kts +26 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +381 -78
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +29 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MetricsMonitor.kt +143 -17
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +7 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/OfflineCacheService.kt +374 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +162 -26
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
- package/dist/native/bridge/WebBridge.js +2 -2
- package/dist/native/bridge/constants/NativeInterfaces.js +2 -1
- package/dist/native/bridge/perf/collectors/ApiCollector.js +22 -0
- package/dist/native/bridge/perf/collectors/CacheCollector.js +40 -0
- package/dist/native/bridge/perf/collectors/HookCollector.js +43 -0
- package/dist/native/bridge/perf/collectors/InsightsCollector.js +47 -0
- package/dist/native/bridge/perf/collectors/InteractionCollector.js +38 -0
- package/dist/native/bridge/perf/collectors/KeyboardCollector.js +25 -0
- package/dist/native/bridge/perf/collectors/NavigationCollector.js +53 -0
- package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +7 -0
- package/dist/native/bridge/perf/collectors/RenderCollector.js +40 -0
- package/dist/native/bridge/perf/collectors/ScrollCollector.js +25 -0
- package/dist/native/bridge/perf/collectors/ThreadCollector.js +27 -0
- package/dist/native/bridge/perf/core/constants.js +43 -0
- package/dist/native/bridge/perf/core/measure.js +40 -0
- package/dist/native/bridge/perf/core/session.js +34 -0
- package/dist/native/bridge/perf/core/store.js +1 -0
- package/dist/native/bridge/perf/core/summary.js +1 -0
- package/dist/native/bridge/perf/core/traceExport.js +1 -0
- package/dist/native/bridge/perf/core/waterfalls.js +1 -0
- package/dist/native/bridge/perf/index.js +81 -0
- package/dist/native/buildAndroid/assets.js +2 -2
- package/dist/native/buildIos/config.js +1 -1
- package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
- package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
- package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +49 -4
- package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
- package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +141 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +157 -3
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
- package/dist/native/renameAndroidProject.js +2 -2
- package/dist/offline/catalyst-sw.js +1 -0
- package/dist/offline/registerServiceWorker.js +1 -0
- package/dist/router/ClientRouter.js +1 -1
- package/dist/scripts/build.js +1 -1
- package/dist/scripts/devBuild.js +1 -1
- package/dist/scripts/generateOfflineManifest.js +1 -0
- package/dist/server/expressServer.js +2 -1
- package/dist/server/renderer/document/Body.js +1 -1
- package/dist/server/renderer/handler.js +3 -3
- package/dist/webpack/base.babel.js +1 -1
- package/mcp_v2/knowledge-base.json +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.InteractionCollector=void 0;var _constants=require("../core/constants.js");var _session=require("../core/session.js");/**
|
|
2
|
+
* InteractionCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #6, #8, #13, #17
|
|
5
|
+
* #6 blank screens → tap → no paint → LoAF blocked first render
|
|
6
|
+
* detected as: interaction span with no following rAF / LoAF overlap
|
|
7
|
+
* #8 long press context menu → pointerdown held > 500ms = native context menu will appear
|
|
8
|
+
* detected as: long-press span so devs see which elements trigger it
|
|
9
|
+
* #13 cart jank haywire clicks → interaction timestamps vs render timestamps diverge
|
|
10
|
+
* detected as: tap → paint delta > INTERACTION_SLOW_MS
|
|
11
|
+
* #17 prescription modal wrong → event queue backed up: pointerdown fires long after it was queued
|
|
12
|
+
* detected as: slow interaction span overlapping LoAF
|
|
13
|
+
*
|
|
14
|
+
* Spans emitted:
|
|
15
|
+
* catalyst:session/interaction|<tag>#<id> — currently disabled; preserved below as the
|
|
16
|
+
* old 2s grouping window for future re-enable.
|
|
17
|
+
* catalyst:interaction|<tag>#<id> — pointerdown → next rAF (Track: Interaction)
|
|
18
|
+
* detail: responseMs, loafOverlap, target, sessionId
|
|
19
|
+
* catalyst:long-press|<tag> — pointerdown held > 500ms (Track: Interaction)
|
|
20
|
+
* detail: target, heldMs
|
|
21
|
+
*
|
|
22
|
+
* Correlation:
|
|
23
|
+
* A lightweight interaction ID remains active from pointerdown to the next frame.
|
|
24
|
+
* Related work is tagged without emitting a parent session track.
|
|
25
|
+
*
|
|
26
|
+
* Disabled session nesting:
|
|
27
|
+
* On pointerdown, a 2-second interaction session opens.
|
|
28
|
+
* RenderCollector queries activeSessionId to tag its spans.
|
|
29
|
+
* Session closes after SESSION_WINDOW_MS (2s) or next pointerdown, whichever comes first.
|
|
30
|
+
*/const SESSION_WINDOW_MS=2000;const MAX_RECENT_INTERACTIONS=50;class InteractionCollector{constructor(measure){this._measure=measure;this._pendingTaps=new Map();// pointerId → { startMark, startTime, target }
|
|
31
|
+
this._session=new _session.Session(_constants.PREFIX.SESSION_INTERACTION,measure,_constants.TRACK.INTERACTION);this._sessionTimer=null;this._sessionId=null;this._sessionNetworkCalls=0;this._sessionFpsDrop=false;this._activeInteraction=null;this._recentInteractions=[];this._interactionSeq=0;}// ─── Public API — queried by native API and render collectors ──────────────
|
|
32
|
+
get activeInteractionId(){return this._activeInteraction?.id??null;}interactionIdForRange(startTime,endTime=startTime){const interactions=this._activeInteraction?[...this._recentInteractions,this._activeInteraction]:this._recentInteractions;return interactions.slice().reverse().find(interaction=>startTime<=(interaction.endTime??performance.now())&&endTime>=interaction.startTime)?.id??null;}/** Called by index.js to wire up insights notifications. */setInsightsSource(fn){this._notifyInsights=fn;}/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}/** Called by RenderCollector when an fps-drop-episode fires. */onFpsDrop(){if(this._session.isOpen){this._sessionFpsDrop=true;this._session.set("fpsDrop",true);}}/** Called by NetworkTimingCollector when a fetch/XHR starts during an interaction. */onNetworkCall(){if(this._session.isOpen){this._sessionNetworkCalls++;this._session.set("networkCalls",this._sessionNetworkCalls);}}init(){document.addEventListener("pointerdown",this._onPointerDown.bind(this),{passive:true});document.addEventListener("pointerup",this._onPointerUp.bind(this),{passive:true});document.addEventListener("pointercancel",this._onPointerCancel.bind(this),{passive:true});}_openSession(target,startTime){// Close any existing session immediately — new tap = new session
|
|
33
|
+
this._closeSession();this._sessionId=`${Math.round(startTime)}`;this._sessionNetworkCalls=0;this._sessionFpsDrop=false;this._session.open({target,sessionId:this._sessionId,networkCalls:0,fpsDrop:false},startTime);this._notifyWaterfall?.({phase:"start",target,sessionId:this._sessionId,startTime});// Auto-close after 2s
|
|
34
|
+
this._sessionTimer=setTimeout(()=>this._closeSession(),SESSION_WINDOW_MS);}_closeSession(){if(this._sessionTimer){clearTimeout(this._sessionTimer);this._sessionTimer=null;}if(this._session.isOpen){const target=this._session.get("target");const startTime=this._session.startTime;const endTime=performance.now();this._session.close({sessionId:this._sessionId,networkCalls:this._sessionNetworkCalls,fpsDrop:this._sessionFpsDrop},endTime,this._sessionFpsDrop?"error":"secondary");this._notifyWaterfall?.({phase:"end",target,sessionId:this._sessionId,networkCalls:this._sessionNetworkCalls,fpsDrop:this._sessionFpsDrop,startTime,endTime,durationMs:Math.round(endTime-startTime)});}this._sessionId=null;}_onPointerDown(e){const startTime=performance.now();const target=this._describeTarget(e.target);const startMark=`${_constants.PREFIX.INTERACTION}:${e.pointerId}:start`;const interactionId=`Tap #${++this._interactionSeq}`;performance.mark(startMark,{startTime});this._activeInteraction={id:interactionId,startTime,endTime:null};this._measure.marker(`${interactionId}: ${target}`,startTime,{interactionId,target},"secondary");// The old 2s parent session
|
|
35
|
+
// this._openSession(target, startTime)
|
|
36
|
+
// Long-press detection timer
|
|
37
|
+
const longPressTimer=setTimeout(()=>{this._emitLongPress(e.pointerId,target,startTime);},_constants.THRESHOLD.LONG_PRESS_MS);this._pendingTaps.set(e.pointerId,{startMark,startTime,target,interactionId,longPressTimer});// Measure tap → next paint (interaction responsiveness)
|
|
38
|
+
requestAnimationFrame(()=>{const endTime=performance.now();const tap=this._pendingTaps.get(e.pointerId);if(!tap)return;tap.frameComplete=true;if(tap.released)this._pendingTaps.delete(e.pointerId);this._rememberInteraction(interactionId,startTime,endTime);const responseMs=Math.round(endTime-startTime);if(responseMs>_constants.THRESHOLD.INTERACTION_SLOW_MS){this._notifyInsights?.({target,responseMs,startTime,endTime,interactionId});}this._measure.emit(`${interactionId}: ${target} - ${responseMs}ms`,startMark,endTime,{responseMs,loafOverlap:this._measure.overlapsLoaf(startTime,endTime),target,interactionId,simulatorValid:false},_constants.TRACK.INTERACTION);this._notifyWaterfall?.({phase:"end",target,sessionId:interactionId,networkCalls:0,fpsDrop:false,startTime,endTime,durationMs:responseMs,responseMs,shortInteraction:true});});}_onPointerUp(e){const tap=this._pendingTaps.get(e.pointerId);if(tap){clearTimeout(tap.longPressTimer);tap.released=true;if(tap.frameComplete)this._pendingTaps.delete(e.pointerId);}}_onPointerCancel(e){const tap=this._pendingTaps.get(e.pointerId);if(!tap)return;clearTimeout(tap.longPressTimer);this._pendingTaps.delete(e.pointerId);if(this._activeInteraction?.id===tap.interactionId)this._activeInteraction=null;}_rememberInteraction(id,startTime,endTime){this._recentInteractions.push({id,startTime,endTime});if(this._recentInteractions.length>MAX_RECENT_INTERACTIONS){this._recentInteractions.shift();}if(this._activeInteraction?.id===id)this._activeInteraction=null;}_emitLongPress(pointerId,target,startTime){const tap=this._pendingTaps.get(pointerId);if(!tap)return;const endTime=performance.now();const heldMs=Math.round(endTime-startTime);const markName=`${_constants.PREFIX.LONG_PRESS}:${Math.round(startTime)}`;performance.mark(markName,{startTime});this._measure.emit(`${tap.interactionId}: Long press ${target} - ${heldMs}ms`,markName,endTime,{target,heldMs,interactionId:tap.interactionId,simulatorValid:true},_constants.TRACK.INTERACTION,"tertiary");}_describeTarget(el){if(!el)return"unknown";const tag=el.tagName?.toLowerCase()??"unknown";const id=el.id?`#${el.id}`:"";const cls=el.classList?.length?`.${[...el.classList].slice(0,2).join(".")}`:"";return`${tag}${id}${cls}`.slice(0,40);}}exports.InteractionCollector=InteractionCollector;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.KeyboardCollector=void 0;var _constants=require("../core/constants.js");var _session=require("../core/session.js");/**
|
|
2
|
+
* KeyboardCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #3, #12
|
|
5
|
+
* #3 keyboard sticky scroll → keyboard-show → viewport-resize → scroll fires during session
|
|
6
|
+
* if sticky element recalcs during this window it causes thrash
|
|
7
|
+
* #12 search input no auto-focus → keyboard-show event should fire after route transition to search
|
|
8
|
+
* if it doesn't, the native WebView suppressed programmatic focus
|
|
9
|
+
*
|
|
10
|
+
* Native events received (via index.js router):
|
|
11
|
+
* keyboard-show { nativeTime, keyboardHeight }
|
|
12
|
+
* keyboard-hide { nativeTime }
|
|
13
|
+
*
|
|
14
|
+
* Spans emitted:
|
|
15
|
+
* catalyst:session/keyboard — keyboard-show → viewport settled (Track: Keyboard)
|
|
16
|
+
* detail: keyboardHeight, viewportDelta, hadScrollDuringSession
|
|
17
|
+
* catalyst:viewport-resize — visualViewport resize → next rAF (Track: Keyboard)
|
|
18
|
+
* detail: deltaHeight
|
|
19
|
+
*/class KeyboardCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;this._session=new _session.Session(_constants.PREFIX.SESSION_KEYBOARD,measure,_constants.TRACK.INPUT);}init(){this._observeVisualViewport();}setWaterfallSource(fn){this._notifyWaterfall=fn;}// ─── Native event handlers ────────────────────────────────────────────────
|
|
20
|
+
onKeyboardShow(event){const webNow=this._nativeToWeb(event.nativeTime);this._session.open({keyboardHeight:event.keyboardHeight,hadScroll:false},webNow);}onKeyboardHide(event){const webNow=this._nativeToWeb(event.nativeTime);if(this._session.isOpen){this._closeSession(webNow);}// Emit a point mark so keyboard-hide is visible on the timeline
|
|
21
|
+
const hideMark=`catalyst:keyboard-hide:${Math.round(webNow)}`;performance.mark(hideMark,{startTime:webNow});}// Called by ScrollCollector (via index.js) if scroll fires while keyboard open
|
|
22
|
+
onScrollDuringKeyboard(){if(this._session.isOpen){this._session.set("hadScroll",true);}}// ─── Viewport resize ──────────────────────────────────────────────────────
|
|
23
|
+
// Fires when keyboard pushes/pulls the viewport.
|
|
24
|
+
// Also closes the keyboard session — viewport settling = keyboard done.
|
|
25
|
+
_observeVisualViewport(){if(!window.visualViewport)return;let lastHeight=window.visualViewport.height;window.visualViewport.addEventListener("resize",()=>{const now=performance.now();const newHeight=window.visualViewport.height;const delta=Math.abs(newHeight-lastHeight);lastHeight=newHeight;if(delta<_constants.THRESHOLD.VIEWPORT_MIN_DELTA)return;const markName=`${_constants.PREFIX.VIEWPORT_RESIZE}:start:${Math.round(now)}`;performance.mark(markName,{startTime:now});requestAnimationFrame(()=>{const endTime=performance.now();const durationMs=Math.round(endTime-now);this._measure.emit(_constants.PREFIX.VIEWPORT_RESIZE,markName,endTime,{deltaHeight:Math.round(delta),simulatorValid:true},_constants.TRACK.INPUT);this._notifyWaterfall?.({metric:"viewport-resize",startTime:now,endTime,durationMs,deltaHeight:Math.round(delta)});if(this._session.isOpen){this._closeSession(endTime);}});});}_closeSession(endTime){const startTime=this._session.startTime;const detail={keyboardHeight:this._session.get("keyboardHeight"),hadScrollDuringSession:this._session.get("hadScroll"),simulatorValid:false};this._session.close(detail,endTime);this._notifyWaterfall?.({kind:"keyboard",startTime,endTime,durationMs:Math.round(endTime-startTime),...detail});}}exports.KeyboardCollector=KeyboardCollector;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.NavigationCollector=void 0;var _constants=require("../core/constants.js");var _session=require("../core/session.js");/**
|
|
2
|
+
* NavigationCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #2, #7, #9, #11, #14
|
|
5
|
+
* #2 back-forward reloads screen → new nav session opens, cache miss burst follows
|
|
6
|
+
* #7 header disappears on back → route-transition span shows animation torn down early
|
|
7
|
+
* #9 forward transition broken → route-transition fires but no visual end mark
|
|
8
|
+
* #11 banner overlaps search box → nav session ends but layout-shift fires after
|
|
9
|
+
* #14 transitions not soft → skeleton→content swap visible as layout-shift during nav
|
|
10
|
+
*
|
|
11
|
+
* Spans emitted:
|
|
12
|
+
* catalyst:session/navigation — page start → LCP finalized (Track: Navigation)
|
|
13
|
+
* catalyst:route-transition|<from→to> — popstate/hashchange → rAF settle (Track: Navigation)
|
|
14
|
+
* catalyst:lcp|<ms>ms — LCP candidate finalized (Track: Navigation)
|
|
15
|
+
* catalyst:fcp|<ms>ms — FCP (iOS fallback) (Track: Navigation)
|
|
16
|
+
* catalyst:page-load|<url> — native page-load-start → page-load-end (Track: Navigation)
|
|
17
|
+
* catalyst:cold-start|<ms>ms — native app cold start duration (Track: Navigation)
|
|
18
|
+
* catalyst:boot/activity-created — batch: MainActivity.onCreate (Track: Navigation)
|
|
19
|
+
* catalyst:boot/webview-constructed — batch: CustomWebView init{} (Track: Navigation)
|
|
20
|
+
* catalyst:boot/load-url — batch: WebView.loadUrl() call (Track: Navigation)
|
|
21
|
+
* catalyst:boot/page-started — batch: onPageStarted callback (Track: Navigation)
|
|
22
|
+
* catalyst:boot/page-finished — batch: onPageFinished callback (Track: Navigation)
|
|
23
|
+
*/class NavigationCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb??(t=>t);this._session=new _session.Session(_constants.PREFIX.SESSION_NAV,measure,_constants.TRACK.NAVIGATION);this._pendingLcp=null;// { entry, label } — buffered until finalized
|
|
24
|
+
this._lcpFinalized=false;// Track pending native page-load spans: url → { markName, startWeb }
|
|
25
|
+
this._pendingPageLoads=new Map();}init(){this._session.open();this._observeLCP();this._observeRouteTransitions();this._setupLcpFinalization();}/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}// Called by other collectors to check if nav is still loading
|
|
26
|
+
get isLoading(){return this._session.isOpen;}// ─── LCP ──────────────────────────────────────────────────────────────────
|
|
27
|
+
_observeLCP(){const types=PerformanceObserver.supportedEntryTypes??[];const type=types.includes("largest-contentful-paint")?"largest-contentful-paint":types.includes("paint")?"paint":null;if(!type)return;const obs=new PerformanceObserver(list=>{for(const entry of list.getEntries()){if(type==="paint"&&entry.name!=="first-contentful-paint")continue;const label=type==="largest-contentful-paint"?"lcp":"fcp";this._pendingLcp={entry,label};// overwrite — keep latest candidate
|
|
28
|
+
}});obs.observe({type,buffered:true});}_setupLcpFinalization(){// Browser stops updating LCP candidate on first user interaction or page hide
|
|
29
|
+
const finalize=()=>{this._finalizeLcp();document.removeEventListener("pointerdown",finalize);document.removeEventListener("keydown",finalize);};document.addEventListener("pointerdown",finalize);document.addEventListener("keydown",finalize);document.addEventListener("visibilitychange",()=>{if(document.visibilityState==="hidden")this._finalizeLcp();});}_finalizeLcp(){if(this._lcpFinalized||!this._pendingLcp)return;this._lcpFinalized=true;const{entry,label}=this._pendingLcp;this._pendingLcp=null;const markName=`${_constants.PREFIX[label.toUpperCase()]}:mark`;performance.mark(markName,{startTime:0});this._measure.emit(`${_constants.PREFIX[label.toUpperCase()]}|${Math.round(entry.startTime)}ms`,markName,entry.startTime,{renderTime:entry.renderTime??null,loadTime:entry.loadTime??null,simulatorValid:false},_constants.TRACK.NAVIGATION);this._notifyWaterfall?.({label:label.toUpperCase(),startTime:0,endTime:entry.startTime,durationMs:Math.round(entry.startTime)});// LCP finalized = page is loaded — close the navigation session
|
|
30
|
+
this._session.close({simulatorValid:false},entry.startTime);}// ─── Route transitions ────────────────────────────────────────────────────
|
|
31
|
+
// Detects SPA route changes via popstate + hashchange.
|
|
32
|
+
// Emits a span from event fire → next rAF (visual settle).
|
|
33
|
+
//
|
|
34
|
+
// Covers:
|
|
35
|
+
// #7 — if rAF never fires cleanly, transition span shows the gap
|
|
36
|
+
// #9 — span fires on back but not on some forward routes → missing spans = broken routes
|
|
37
|
+
// #11 — nav session re-opens; layout-shift collector shows shift after session close
|
|
38
|
+
_observeRouteTransitions(){let _fromPath=location.pathname+location.hash;const onTransition=e=>{const from=_fromPath;const to=location.pathname+location.hash;_fromPath=to;const startTime=performance.now();const markName=`${_constants.PREFIX.ROUTE_TRANSITION}:start:${Math.round(startTime)}`;performance.mark(markName,{startTime});// Re-open nav session for the SPA route change.
|
|
39
|
+
// LCP only fires once per WebView load — it does not reset on pushState.
|
|
40
|
+
// So we can't wait for LCP to close the session. Instead, close it after
|
|
41
|
+
// the next rAF (visual settle) + a short timeout to catch deferred renders.
|
|
42
|
+
if(!this._session.isOpen){this._lcpFinalized=false;this._session.open({},startTime);this._setupLcpFinalization();// Fallback: if LCP doesn't fire (SPA), close after rAF + 1s
|
|
43
|
+
requestAnimationFrame(()=>{setTimeout(()=>{if(this._session.isOpen&&!this._lcpFinalized){this._session.close({simulatorValid:true},performance.now());}},1000);});}requestAnimationFrame(()=>{const endTime=performance.now();this._measure.emit(`${_constants.PREFIX.ROUTE_TRANSITION}|${from}→${to}`,markName,endTime,{from,to,type:e.type,// 'popstate' or 'hashchange'
|
|
44
|
+
simulatorValid:true},_constants.TRACK.NAVIGATION);this._notifyWaterfall?.({label:`Route ${to}`,startTime,endTime,durationMs:Math.round(endTime-startTime)});});};window.addEventListener("popstate",onTransition);window.addEventListener("hashchange",onTransition);}// ─── Native page-load events (from CustomWebview.kt) ─────────────────────
|
|
45
|
+
// page-load-start: fired by onPageStarted, carries nativeTime + url
|
|
46
|
+
// page-load-end: fired by onPageFinished, carries nativeTime + url + durationMs
|
|
47
|
+
// page-load-error: fired by onReceivedError, carries nativeTime + url + errorCode
|
|
48
|
+
onNativeEvent(event){switch(event.type){case"page-load-start":{const startWeb=this._nativeToWeb(event.nativeTime);const markName=`${_constants.PREFIX.PAGE_LOAD}:start:${Math.round(startWeb)}`;performance.mark(markName,{startTime:startWeb});this._pendingPageLoads.set(event.url,{markName,startWeb});break;}case"page-load-end":{const pending=this._pendingPageLoads.get(event.url);if(!pending)break;this._pendingPageLoads.delete(event.url);const endWeb=this._nativeToWeb(event.nativeTime);const durMs=event.durationMs??Math.round(endWeb-pending.startWeb);const isSlow=durMs>2500;this._measure.emit(`${_constants.PREFIX.PAGE_LOAD}|${event.url.split("/").pop()||event.url}`,pending.markName,endWeb,{url:event.url,durationMs:durMs,simulatorValid:true},_constants.TRACK.NAVIGATION,isSlow?"error":"primary");break;}case"page-load-error":{const pending=this._pendingPageLoads.get(event.url);const startWeb=pending?pending.startWeb:this._nativeToWeb(event.nativeTime);const markName=pending?pending.markName:`${_constants.PREFIX.PAGE_LOAD}:err:${Math.round(startWeb)}`;if(!pending)performance.mark(markName,{startTime:startWeb});this._pendingPageLoads.delete(event.url);const errEnd=this._nativeToWeb(event.nativeTime)+1;this._measure.emit(`${_constants.PREFIX.PAGE_LOAD}/error|${event.errorCode??"?"}`,markName,errEnd,{url:event.url,errorCode:event.errorCode,description:event.description??"",simulatorValid:true},_constants.TRACK.NAVIGATION,"error");break;}case"cold-start":{// emitted by markAppStartComplete() — point-in-time span at page init
|
|
49
|
+
const startWeb=this._nativeToWeb(event.nativeTime);const markName=`${_constants.PREFIX.COLD_START}:mark`;performance.mark(markName,{startTime:Math.max(0,startWeb-event.durationMs)});this._measure.emit(`${_constants.PREFIX.COLD_START}|${event.durationMs}ms`,markName,startWeb,{durationMs:event.durationMs,simulatorValid:true},_constants.TRACK.NAVIGATION,event.durationMs>3000?"error":"secondary");break;}}}// ─── Batch boot timing events ─────────────────────────────────────────────
|
|
50
|
+
// Emitted from PerfEventBuffer flush after onPageFinished.
|
|
51
|
+
// Each boot-* event is a point-in-time marker on Catalyst > Navigation.
|
|
52
|
+
onBatchEvent(event){if(event.type==="navigation-back"){const t=this._nativeToWeb(event.nativeTime);const markName=`catalyst:navigation/back:${Math.round(t)}`;performance.mark(markName,{startTime:Math.max(0,t)});this._measure.emit("catalyst:navigation/back",markName,Math.max(0,t)+1,{simulatorValid:false},_constants.TRACK.NAVIGATION,_constants.COLOR.SECONDARY);return;}const bootTypeMap={"boot-activity-created":"activity-created","boot-webview-constructed":"webview-constructed","boot-load-url":"load-url","boot-page-started":"page-started","boot-page-finished":"page-finished"};const label=bootTypeMap[event.type];if(!label)return;const t=this._nativeToWeb(event.nativeTime);const markName=`catalyst:boot/${label}:${Math.round(t)}`;performance.mark(markName,{startTime:Math.max(0,t)});this._measure.emit(`catalyst:boot/${label}`,markName,Math.max(0,t)+1,// point span
|
|
53
|
+
{nativeTime:event.nativeTime,thread:event.thread??null,simulatorValid:false},_constants.TRACK.NAVIGATION,_constants.COLOR.SECONDARY_LIGHT);}}exports.NavigationCollector=NavigationCollector;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.NetworkTimingCollector=void 0;var _constants=require("../core/constants.js");/**
|
|
2
|
+
* NetworkTimingCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Adds lightweight Catalyst timeline spans for fetch() and XMLHttpRequest.
|
|
5
|
+
* Chrome already has detailed network timing; these spans exist to attach
|
|
6
|
+
* network work to Catalyst interaction sessions via sessionId.
|
|
7
|
+
*/const FETCH_WRAPPED="__catalystNetworkTimingFetchWrapped";const XHR_WRAPPED="__catalystNetworkTimingXhrWrapped";const XHR_META="__catalystNetworkTimingMeta";const MAX_LABEL_LENGTH=72;class NetworkTimingCollector{constructor(measure){this._measure=measure;this._getInteraction=null;this._requestSeq=0;}setInteractionSource(fn){this._getInteraction=fn;}setWaterfallSource(fn){this._notifyWaterfall=fn;}init(){if(typeof window==="undefined")return;this._wrapFetch();this._wrapXhr();}_wrapFetch(){const originalFetch=window.fetch;if(typeof originalFetch!=="function"||originalFetch[FETCH_WRAPPED])return;const collector=this;function catalystFetch(input,init){const span=collector._start("fetch",init?.method??input?.method??"GET",init?.url??input?.url??input);try{return originalFetch.apply(this,arguments).then(response=>{collector._finish(span,{status:response?.status??null,ok:response?.ok??null});return response;},error=>{collector._finish(span,{outcome:"error",ok:false});throw error;});}catch(error){collector._finish(span,{outcome:"error",ok:false});throw error;}}catalystFetch[FETCH_WRAPPED]=true;window.fetch=catalystFetch;}_wrapXhr(){const proto=window.XMLHttpRequest?.prototype;if(!proto||proto[XHR_WRAPPED])return;if(typeof proto.open!=="function"||typeof proto.send!=="function")return;const originalOpen=proto.open;const originalSend=proto.send;const collector=this;proto.open=function catalystXhrOpen(method,url){this[XHR_META]={method,url};return originalOpen.apply(this,arguments);};proto.send=function catalystXhrSend(){const meta=this[XHR_META]??{};const span=collector._start("xhr",meta.method??"GET",meta.url??"unknown");let emitted=false;const finish=(outcome="complete")=>{if(emitted)return;emitted=true;const status=this.status;collector._finish(span,{status,ok:collector._statusOk(status),outcome});};this.addEventListener?.("loadend",()=>finish(),{once:true});try{return originalSend.apply(this,arguments);}catch(error){finish("error");throw error;}};proto[XHR_WRAPPED]=true;}_start(api,method,rawUrl){const startTime=performance.now();const normalizedMethod=String(method||"GET").toUpperCase();const url=this._normalizeUrl(rawUrl);const label=this._labelForUrl(url);const requestId=`${api}:${++this._requestSeq}`;const startMark=`${_constants.PREFIX.NETWORK_TIMING}:${requestId}:start`;performance.mark(startMark,{startTime});const interaction=this._getInteraction?.();const interactionId=interaction?.activeInteractionId??null;if(interactionId)interaction?.onNetworkCall?.();return{api,method:normalizedMethod,url,label,requestId,startMark,startTime,interactionId};}_finish(span,result={}){const endTime=performance.now();const durationMs=Math.round(endTime-span.startTime);const outcome=result.outcome??"complete";const ok=result.ok??this._statusOk(result.status);const color=outcome!=="complete"||ok===false||durationMs>_constants.THRESHOLD.FETCH_SLOW_MS?_constants.COLOR.ERROR:_constants.COLOR.SECONDARY;const detail={api:span.api,method:span.method,url:span.url,status:result.status??null,ok,outcome,durationMs,sessionId:span.interactionId,requestId:span.requestId,simulatorValid:true};this._notifyWaterfall?.({...detail,startTime:span.startTime,endTime});this._measure.emit(`${span.interactionId?`[${span.interactionId}] `:""}Network: ${span.method} ${span.label} - ${durationMs}ms`,span.startMark,endTime,{interactionId:span.interactionId,...detail,sessionId:undefined},_constants.TRACK.NETWORK,color);}_normalizeUrl(rawUrl){const value=rawUrl?.url??rawUrl??"unknown";try{const parsed=new URL(String(value),window.location?.href);return`${parsed.origin}${parsed.pathname}`;}catch{return String(value).split("?")[0].split("#")[0];}}_labelForUrl(url){try{const parsed=new URL(url,window.location?.href);const label=parsed.origin===window.location?.origin?parsed.pathname:`${parsed.host}${parsed.pathname}`;return(label||"/").slice(0,MAX_LABEL_LENGTH);}catch{return String(url||"unknown").slice(0,MAX_LABEL_LENGTH);}}_statusOk(status){return typeof status==="number"&&status>=200&&status<400;}}exports.NetworkTimingCollector=NetworkTimingCollector;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.RenderCollector=void 0;var _constants=require("../core/constants.js");/**
|
|
2
|
+
* RenderCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #1, #6, #10, #14, #15, #16
|
|
5
|
+
* #1 page slowness / cache miss → LoAF during navigation session = render blocked by loading
|
|
6
|
+
* #6 blank screens → LoAF blocks first render; LCP may never fire
|
|
7
|
+
* #10 footer flash on Drug/OTC → layout-shift fires just after navigation session closes
|
|
8
|
+
* #14 transitions not soft → layout-shift during nav = skeleton→content swap too abrupt
|
|
9
|
+
* #15 quantity selector slow → LoAF burst in the 2s after interaction (lazy chunk blocks)
|
|
10
|
+
* #16 prescription screen touch lag → same LoAF burst pattern after tap
|
|
11
|
+
*
|
|
12
|
+
* Spans emitted:
|
|
13
|
+
* catalyst:loaf|<ms>ms — long animation frame (Track: Render)
|
|
14
|
+
* detail: blockingDuration, scriptCount, sessionContext
|
|
15
|
+
* catalyst:layout-shift|<value> — unexpected layout shift (Track: Render)
|
|
16
|
+
* detail: value, sessionContext
|
|
17
|
+
* catalyst:fps-drop|<fps>fps-min|<ms>ms — native fps-drop episode (Track: Render)
|
|
18
|
+
* detail: minFps, avgFps, durationMs, sessionContext
|
|
19
|
+
* closes on recovery >55fps OR after 10s max
|
|
20
|
+
* catalyst:long-task|<ms>ms — native long task > 16ms (Track: Render)
|
|
21
|
+
* catalyst:mem/jvm|<MB>MB — JVM heap PSS — Kotlin/Java objects (Track: Render, blue)
|
|
22
|
+
* catalyst:mem/native|<MB>MB — native heap PSS — V8 + Blink + JNI, WebView proxy (Track: Render, orange)
|
|
23
|
+
* catalyst:mem/total|<MB>MB — total process PSS — what Android OOM killer sees (Track: Render, purple→red)
|
|
24
|
+
*
|
|
25
|
+
* Notifies:
|
|
26
|
+
* ScrollCollector.onLoaf() — so scroll session can increment its loafCount
|
|
27
|
+
* HookCollector.onLoaf() — so hook mount sessions can detect LoAF bursts
|
|
28
|
+
*/class RenderCollector{constructor(measure,getSessionContext,nativeToWeb){this._measure=measure;// getSessionContext() returns 'navigation'|'scroll'|'keyboard'|'none'
|
|
29
|
+
// provided by index.js so RenderCollector doesn't import other collectors
|
|
30
|
+
this._getSessionContext=getSessionContext;this._nativeToWeb=nativeToWeb??(t=>t);this._loafListeners=[];// [fn] called on every LoAF
|
|
31
|
+
this._getInteraction=null;// set by index.js after construction
|
|
32
|
+
}/** Called by index.js to wire up interaction session context. */setInteractionSource(fn){this._getInteraction=fn;}/** Called by index.js to wire up insights notifications. */setInsightsSource(fn){this._notifyInsights=fn;}/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}// Allow other collectors to subscribe to LoAF events
|
|
33
|
+
addLoafListener(fn){this._loafListeners.push(fn);}init(){this._observeLoAF();this._observeLayoutShift();}// ─── Native MetricsMonitor events ────────────────────────────────────────
|
|
34
|
+
onNativeEvent(event){const t=this._nativeToWeb(event.nativeTime);switch(event.type){case"fps-drop-episode":{const start=Math.max(0,this._nativeToWeb(event.startNativeTime));const end=Math.max(start,this._nativeToWeb(event.endNativeTime));const markName=`${_constants.PREFIX.FPS_DROP}:${Math.round(start)}`;performance.mark(markName,{startTime:start});const interactionId=this._getInteraction?.()?.interactionIdForRange(start,end)??null;if(interactionId)this._getInteraction?.()?.onFpsDrop();this._measure.emit(`${interactionId?`[${interactionId}] `:""}FPS drop: ${event.minFps.toFixed(1)}fps - ${Math.round(event.durationMs)}ms`,markName,end,{interactionId,minFps:event.minFps,avgFps:event.avgFps,durationMs:event.durationMs,sessionContext:this._getSessionContext(),thread:event.thread??null,simulatorValid:false},_constants.TRACK.RENDER,event.minFps<30?"error":"tertiary");this._notifyWaterfall?.({metric:"fps-drop",startTime:start,endTime:end,durationMs:event.durationMs,minFps:event.minFps,avgFps:event.avgFps,sessionId:interactionId});return{interactionId,startTime:start,endTime:end};}case"long-task":{const markName=`${_constants.PREFIX.LONG_TASK}:${Math.round(t)}`;const interactionId=this._getInteraction?.()?.interactionIdForRange(t,t+event.durationMs)??null;performance.mark(markName,{startTime:t});this._measure.emit(`${interactionId?`[${interactionId}] `:""}Long task: ${event.durationMs}ms`,markName,t+event.durationMs,{interactionId,durationMs:event.durationMs,sessionContext:this._getSessionContext(),thread:event.thread??null,simulatorValid:true},_constants.TRACK.RENDER,event.durationMs>100?"error":"tertiary");this._notifyWaterfall?.({metric:"long-task",startTime:t,endTime:t+event.durationMs,durationMs:event.durationMs,sessionId:interactionId});break;}case"memory-snapshot":{const jvmMark=`catalyst:mem/jvm:${Math.round(t)}`;performance.mark(jvmMark,{startTime:t});this._measure.emit(`JVM memory: ${event.jvmMb.toFixed(1)}MB`,jvmMark,t+1,{memoryId:`memory-jvm-${Math.round(t)}`,jvmMb:event.jvmMb,peakMb:event.peakMb,thread:event.thread??null,simulatorValid:false,...(event.coldStartMs!=null?{coldStartMs:event.coldStartMs}:{})},_constants.TRACK.MEMORY,"primary");const webviewMemoryId=`memory-webview-${Math.round(t)}`;const webviewMark=`catalyst:mem/webview:${Math.round(t)}`;performance.mark(webviewMark,{startTime:t});this._measure.emit(`WebView memory: ${event.webviewMb.toFixed(1)}MB`,webviewMark,t+1,{memoryId:webviewMemoryId,webviewMb:event.webviewMb,otherMb:event.otherMb,peakMb:event.peakMb,thread:event.thread??null,simulatorValid:false,...(event.coldStartMs!=null?{coldStartMs:event.coldStartMs}:{})},_constants.TRACK.MEMORY,"tertiary-dark");const totalMark=`${_constants.PREFIX.MEMORY_TOTAL}:${Math.round(t)}`;performance.mark(totalMark,{startTime:t});this._measure.emit(`Total memory: ${event.totalMb.toFixed(1)}MB`,totalMark,t+1,{memoryId:`memory-total-${Math.round(t)}`,totalMb:event.totalMb,jvmMb:event.jvmMb,webviewMb:event.webviewMb,otherMb:event.otherMb,peakMb:event.peakMb,thread:event.thread??null,simulatorValid:false,...(event.coldStartMs!=null?{coldStartMs:event.coldStartMs}:{})},_constants.TRACK.MEMORY,event.totalMb>400?"error":"secondary");this._notifyInsights?.({currentMb:event.webviewMb,startTime:t,endTime:t+1,memoryId:webviewMemoryId});this._notifyWaterfall?.({metric:"memory",startTime:t,endTime:t+1,durationMs:1,totalMb:event.totalMb,webviewMb:event.webviewMb});break;}}}// ─── Long Animation Frames ────────────────────────────────────────────────
|
|
35
|
+
_observeLoAF(){if(!PerformanceObserver.supportedEntryTypes?.includes("long-animation-frame"))return;const obs=new PerformanceObserver(list=>{for(const entry of list.getEntries()){const start=entry.startTime;const end=start+entry.duration;const context=this._getSessionContext();// Register with shared Measure so all collectors get overlap detection
|
|
36
|
+
this._measure.recordLoaf(start,end);// Notify subscribers (ScrollCollector, HookCollector)
|
|
37
|
+
this._loafListeners.forEach(fn=>fn(start,end));const markName=`${_constants.PREFIX.LOAF}:${Math.round(start)}`;performance.mark(markName,{startTime:start});this._measure.emit(`${_constants.PREFIX.LOAF}|${Math.round(entry.duration)}ms`,markName,end,{blockingDuration:entry.blockingDuration,scriptCount:entry.scripts?.length??0,sessionContext:context,simulatorValid:false},_constants.TRACK.RENDER);this._notifyWaterfall?.({metric:"loaf",startTime:start,endTime:end,durationMs:entry.duration});}});obs.observe({type:"long-animation-frame",buffered:true});}// ─── Layout shift ─────────────────────────────────────────────────────────
|
|
38
|
+
_observeLayoutShift(){if(!PerformanceObserver.supportedEntryTypes?.includes("layout-shift"))return;const obs=new PerformanceObserver(list=>{for(const entry of list.getEntries()){if(entry.hadRecentInput)continue;// user-initiated shift, ignore
|
|
39
|
+
const context=this._getSessionContext();const markName=`catalyst:cls:${Math.round(entry.startTime)}`;performance.mark(markName,{startTime:entry.startTime});if(entry.value>0.1){this._notifyInsights?.({value:entry.value,sessionContext:context,startTime:entry.startTime,endTime:entry.startTime+1});}this._measure.emit(`${_constants.PREFIX.LAYOUT_SHIFT}|${entry.value.toFixed(4)}`,markName,entry.startTime+1,// point-in-time event, minimal duration
|
|
40
|
+
{value:entry.value,sessionContext:context,simulatorValid:false},_constants.TRACK.RENDER);this._notifyWaterfall?.({metric:"layout-shift",startTime:entry.startTime,endTime:entry.startTime+1,durationMs:1,value:entry.value});}});obs.observe({type:"layout-shift",buffered:true});}}exports.RenderCollector=RenderCollector;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.ScrollCollector=void 0;var _constants=require("../core/constants.js");var _session=require("../core/session.js");/**
|
|
2
|
+
* ScrollCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: bugs #3, #13
|
|
5
|
+
* #3 keyboard sticky scroll bug → scroll fires during keyboard session → sticky recalc → layout thrash
|
|
6
|
+
* #13 cart computation jank → fps-drop-episode during scroll → scroll session marked janky
|
|
7
|
+
*
|
|
8
|
+
* Spans emitted:
|
|
9
|
+
* catalyst:session/scroll — scroll-start → scroll-end (Track: Input, shared with keyboard)
|
|
10
|
+
* detail: frameDropCount, minFps, janky, scrollDuration
|
|
11
|
+
*
|
|
12
|
+
* Native events consumed (via index.js _routeNativeEvent):
|
|
13
|
+
* scroll-start { nativeTime } — GestureDetector first onScroll() in CustomWebview.kt
|
|
14
|
+
* scroll-end { nativeTime } — GestureDetector ACTION_UP / ACTION_CANCEL
|
|
15
|
+
*
|
|
16
|
+
* fps-drop-episode routed here (Option B) when scroll session is open:
|
|
17
|
+
* index.js checks this._scroll._session.isOpen before calling onFpsDrop(event)
|
|
18
|
+
*/class ScrollCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;this._session=new _session.Session(_constants.PREFIX.SESSION_SCROLL,measure,_constants.TRACK.INPUT);this._insightsCb=null;// set by index.js via setInsightsSource
|
|
19
|
+
this._frameDropCount=0;this._minFps=60;}init(){// No DOM listener — scroll events come from native GestureDetector
|
|
20
|
+
}setInsightsSource(cb){this._insightsCb=cb;}setWaterfallSource(fn){this._notifyWaterfall=fn;}// Called by RenderCollector when a LoAF fires (shared notification, kept for compat)
|
|
21
|
+
onLoaf(){if(this._session.isOpen){this._session.increment("loafCount");}}// ─── Native event handlers ────────────────────────────────────────────────
|
|
22
|
+
onScrollStart(event){if(this._session.isOpen)return;// already tracking, ignore duplicate start
|
|
23
|
+
const webNow=this._nativeToWeb(event.nativeTime);this._frameDropCount=0;this._minFps=60;this._session.open({loafCount:0},webNow);}onScrollEnd(event){if(!this._session.isOpen)return;const startTime=this._session.startTime;const webNow=this._nativeToWeb(event.nativeTime);const loafCount=this._session.get("loafCount")??0;const janky=this._frameDropCount>0||this._minFps<55;const durationMs=Math.round(webNow-startTime);this._session.close({loafCount,frameDropCount:this._frameDropCount,minFps:this._minFps,janky,simulatorValid:false},webNow);if(janky&&this._insightsCb){this._insightsCb({minFps:this._minFps,frameDropCount:this._frameDropCount,startTime,endTime:webNow});}this._notifyWaterfall?.({kind:"scroll",startTime,endTime:webNow,durationMs,loafCount,frameDropCount:this._frameDropCount,minFps:this._minFps,janky});this._frameDropCount=0;this._minFps=60;}// ─── fps-drop-episode correlation (Option B) ──────────────────────────────
|
|
24
|
+
// Called from index.js _routeNativeEvent when scroll session is open.
|
|
25
|
+
onFpsDrop(event){this._frameDropCount++;if(event.minFps!=null&&event.minFps<this._minFps){this._minFps=event.minFps;}}}exports.ScrollCollector=ScrollCollector;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.ThreadCollector=void 0;var _constants=require("../core/constants.js");/**
|
|
2
|
+
* ThreadCollector.js
|
|
3
|
+
*
|
|
4
|
+
* Instruments: thread visibility gap during cache serving.
|
|
5
|
+
*
|
|
6
|
+
* Problem: when shouldInterceptRequest serves from cache it calls
|
|
7
|
+
* disableHardwareAcceleration() / enableHardwareAcceleration() on the WebView.
|
|
8
|
+
* This creates a software-render window that is invisible in the Render track
|
|
9
|
+
* because it happens on a bg thread and neither the thread name nor the
|
|
10
|
+
* hw-accel state appears in any existing span.
|
|
11
|
+
*
|
|
12
|
+
* Native events received (via index.js router):
|
|
13
|
+
* hw-accel-change { state: 'off'|'on', trigger: 'cache-serve'|'cleanup',
|
|
14
|
+
* thread, nativeTime }
|
|
15
|
+
*
|
|
16
|
+
* Spans emitted (on Render track):
|
|
17
|
+
* catalyst:hw-accel/off — software-render window open (error = bad, overlaps frames)
|
|
18
|
+
* catalyst:hw-accel/on — hardware acceleration restored (primary)
|
|
19
|
+
*
|
|
20
|
+
* Overlap detection:
|
|
21
|
+
* If a LoAF overlaps a software-render window → notifyInsights so
|
|
22
|
+
* InsightsCollector can fire an 'hw-accel-during-frame' critical insight.
|
|
23
|
+
*/class ThreadCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;// Track open software-render windows: { startMark, startTime, thread }
|
|
24
|
+
this._openWindow=null;this._notifyInsights=null;}/** Called by index.js to wire up insights notifications. */setInsightsSource(fn){this._notifyInsights=fn;}init(){// No web-side observers — all events come from native via onNativeEvent()
|
|
25
|
+
}onNativeEvent(event){if(event.type!=="hw-accel-change")return;const{state,trigger,thread,nativeTime}=event;const webTime=nativeTime!=null?this._nativeToWeb(nativeTime):performance.now();if(state==="off"){// Software-render window opens — record start, wait for 'on' to close span
|
|
26
|
+
const markName=`${_constants.PREFIX.HW_ACCEL}/off:start:${Math.round(webTime)}`;performance.mark(markName,{startTime:webTime});this._openWindow={startMark:markName,startTime:webTime,thread:thread??null,trigger:trigger??null};}else if(state==="on"){const endTime=webTime;if(this._openWindow){const{startMark,startTime,thread:offThread,trigger:offTrigger}=this._openWindow;this._openWindow=null;const loafOverlap=this._measure.overlapsLoaf(startTime,endTime);this._measure.emit(`${_constants.PREFIX.HW_ACCEL}/sw-render-window|${offTrigger??"unknown"}`,startMark,endTime,{trigger:offTrigger??null,thread:offThread??null,loafOverlap,simulatorValid:false},_constants.TRACK.RENDER,loafOverlap?_constants.COLOR.ERROR:_constants.COLOR.SECONDARY_DARK);if(loafOverlap){this._notifyInsights?.({durationMs:endTime-startTime,thread:offThread,trigger:offTrigger,startTime,endTime});}}// Emit a point-in-time marker for hw-accel restored
|
|
27
|
+
const onMark=`${_constants.PREFIX.HW_ACCEL}/on:${Math.round(endTime)}`;performance.mark(onMark,{startTime:endTime});this._measure.emit(`${_constants.PREFIX.HW_ACCEL}/restored`,onMark,endTime+1,{trigger:trigger??null,thread:thread??null,simulatorValid:false},_constants.TRACK.RENDER,_constants.COLOR.SECONDARY);}}}exports.ThreadCollector=ThreadCollector;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.TRACK_GROUP=exports.TRACK=exports.THRESHOLD=exports.PREFIX=exports.COLOR=void 0;/**
|
|
2
|
+
* constants.js
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for track names, thresholds, and color rules.
|
|
5
|
+
* All collectors import from here — no magic strings scattered across files.
|
|
6
|
+
*/// ─── Chrome DevTools track group ─────────────────────────────────────────────
|
|
7
|
+
const TRACK_GROUP=exports.TRACK_GROUP="Catalyst";const TRACK=exports.TRACK={NAVIGATION:"Navigation",SCROLL:"Scroll",INPUT:"Input",KEYBOARD:"Keyboard",// alias kept for backward compat — collectors use TRACK.INPUT
|
|
8
|
+
RENDER:"Render",MEMORY:"Memory",CACHE:"Cache",NETWORK:"Network",HOOK:"Hook",INTERACTION:"Interaction",INSIGHTS:"Insights",NATIVE_API:"Native API"};// ─── Measure name prefixes ────────────────────────────────────────────────────
|
|
9
|
+
const PREFIX=exports.PREFIX={SESSION_NAV:"catalyst:session/navigation",SESSION_SCROLL:"catalyst:session/scroll",SESSION_INPUT:"catalyst:session/input",SESSION_KEYBOARD:"catalyst:session/keyboard",// alias — KeyboardCollector still uses this
|
|
10
|
+
LCP:"catalyst:lcp",FCP:"catalyst:fcp",LOAF:"catalyst:loaf",LAYOUT_SHIFT:"catalyst:layout-shift",VIEWPORT_RESIZE:"catalyst:viewport-resize",ROUTE_TRANSITION:"catalyst:route-transition",CACHE:"catalyst:cache",CACHE_GROUP:"catalyst:cache-group",NETWORK_TIMING:"catalyst:network",NATIVE_API:"catalyst:native-api",HOOK_MOUNT:"catalyst:hook/mount",HOOK_LAZY:"catalyst:hook/lazy",INTERACTION:"catalyst:interaction",SESSION_INTERACTION:"catalyst:session/interaction",LONG_PRESS:"catalyst:long-press",INSIGHT:"catalyst:insight",HW_ACCEL:"catalyst:hw-accel",// Native MetricsMonitor events
|
|
11
|
+
PAGE_LOAD:"catalyst:page-load",COLD_START:"catalyst:cold-start",FPS_DROP:"catalyst:fps-drop",LONG_TASK:"catalyst:long-task",MEMORY_JVM:"catalyst:mem/jvm",MEMORY_NATIVE:"catalyst:mem/native",MEMORY_TOTAL:"catalyst:mem/total"};// ─── Thresholds ───────────────────────────────────────────────────────────────
|
|
12
|
+
const THRESHOLD=exports.THRESHOLD={LCP_BAD_MS:2500,// LCP > 2.5s = poor (Core Web Vitals)
|
|
13
|
+
NAV_SESSION_BAD_MS:2500,// navigation session > 2.5s = slow page
|
|
14
|
+
FETCH_SLOW_MS:300,// network fetch > 300ms = slow
|
|
15
|
+
SCROLL_IDLE_MS:150,// scroll ends after 150ms of no scroll events
|
|
16
|
+
VIEWPORT_MIN_DELTA:50,// ignore viewport resize < 50px (not keyboard)
|
|
17
|
+
CACHE_GROUP_IDLE_MS:300,// close cache group after 300ms idle
|
|
18
|
+
LOAF_WINDOW:5,// keep last N LoAF entries for overlap detection
|
|
19
|
+
LONG_PRESS_MS:500,// pointerdown held > 500ms = long press
|
|
20
|
+
INTERACTION_SLOW_MS:100,// tap → paint > 100ms = slow response
|
|
21
|
+
LAZY_LOAF_WINDOW_MS:2000,// watch for LoAF bursts up to 2s after interaction
|
|
22
|
+
CLS_BAD:0.1// CLS > 0.1 per entry = significant shift
|
|
23
|
+
};// ─── Colors ───────────────────────────────────────────────────────────────────
|
|
24
|
+
// Chrome DevTools Extensibility API color values (all 9 values):
|
|
25
|
+
//
|
|
26
|
+
// primary / primary-light / primary-dark → blue tones
|
|
27
|
+
// secondary / secondary-light / secondary-dark → purple/grey tones
|
|
28
|
+
// tertiary / tertiary-light / tertiary-dark → yellow/orange tones
|
|
29
|
+
// error → red
|
|
30
|
+
//
|
|
31
|
+
// Usage guide:
|
|
32
|
+
// error = bad outcome (LoAF, miss, slow native API, memory critical)
|
|
33
|
+
// primary = cache HIT memory (fastest path)
|
|
34
|
+
// primary-light = cache HIT disk (good, slightly slower)
|
|
35
|
+
// primary-dark = good LCP, clean scroll session
|
|
36
|
+
// secondary = informational (native API, keyboard, viewport, mem/total normal)
|
|
37
|
+
// primary = mem/jvm (JVM heap — your Kotlin/Java code)
|
|
38
|
+
// tertiary-dark = mem/native (V8 + Blink + JNI — WebView proxy; watch for growth)
|
|
39
|
+
// secondary-light = route transition, hook mount (neutral)
|
|
40
|
+
// tertiary = network fetch after miss (expected but slow)
|
|
41
|
+
// tertiary-light = small CLS, long-press (low signal)
|
|
42
|
+
// tertiary-dark = insight warning (non-critical)
|
|
43
|
+
const COLOR=exports.COLOR={ERROR:"error",PRIMARY:"primary",PRIMARY_LIGHT:"primary-light",PRIMARY_DARK:"primary-dark",SECONDARY:"secondary",SECONDARY_LIGHT:"secondary-light",SECONDARY_DARK:"secondary-dark",TERTIARY:"tertiary",TERTIARY_LIGHT:"tertiary-light",TERTIARY_DARK:"tertiary-dark"};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.Measure=void 0;var _constants=require("./constants.js");/**
|
|
2
|
+
* measure.js
|
|
3
|
+
*
|
|
4
|
+
* Shared _measure() and _colorFor() logic used by every collector.
|
|
5
|
+
* Injected as a dependency — collectors never call performance.measure() directly.
|
|
6
|
+
*
|
|
7
|
+
* Chrome DevTools Extensibility API rules (Chrome 128+):
|
|
8
|
+
* - detail must be { devtools: { dataType:'track-entry', ... } }
|
|
9
|
+
* - custom data goes in properties[] as [key, value] pairs
|
|
10
|
+
* - start AND end must both be numeric ms timestamps — passing a mark name
|
|
11
|
+
* string as start causes silent fallback to Timings row (confirmed Chrome 145)
|
|
12
|
+
*/const INTERNAL_PROPERTIES=new Set(["simulatorValid"]);const PROPERTY_LABELS={interactionId:"Related tap",parentOperation:"Parent operation",callId:"Call ID",requestId:"Request ID",memoryId:"Memory sample"};const PROPERTY_PRIORITY={interactionId:0,parentOperation:1,outcome:2,ok:3,status:4,durationMs:5,roundTripMs:5,responseMs:5};class Measure{constructor(){// Rolling window of recent LoAF entries — shared across all collectors
|
|
13
|
+
// so any collector can check loafOverlap without knowing about others.
|
|
14
|
+
this._recentLoafs=[];}/**
|
|
15
|
+
* Record a LoAF entry so overlap detection stays current.
|
|
16
|
+
* Called by RenderCollector whenever a long-animation-frame fires.
|
|
17
|
+
*/recordLoaf(start,end){this._recentLoafs.push({start,end});if(this._recentLoafs.length>_constants.THRESHOLD.LOAF_WINDOW){this._recentLoafs.shift();}}overlapsLoaf(startMs,endMs){return this._recentLoafs.some(loaf=>startMs<loaf.end&&endMs>loaf.start);}/**
|
|
18
|
+
* Emit a performance.measure() with Chrome DevTools Extensibility API metadata.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} name - measure name (catalyst:...)
|
|
21
|
+
* @param {string} startMark - name of the opening performance.mark()
|
|
22
|
+
* @param {number} endTime - end time in ms (performance.now()-space)
|
|
23
|
+
* @param {object} detail - arbitrary detail fields added as properties[]
|
|
24
|
+
* @param {string} track - Chrome DevTools track name (from TRACK constant)
|
|
25
|
+
* @param {string} [color] - override color; if omitted, derived from name+detail
|
|
26
|
+
* @param {string} [trackGroup] - Chrome DevTools track group
|
|
27
|
+
*/emit(name,startMark,endTime,detail={},track,color=null,trackGroup=_constants.TRACK_GROUP){try{const startTime=performance.getEntriesByName(startMark,"mark")[0]?.startTime;if(startTime==null){console.warn("[CatalystPerf] measure skipped — mark not found:",startMark);return;}const resolvedColor=color??this._colorFor(name,detail,startTime,endTime);const properties=this._properties(detail);performance.measure(name,{start:startTime,end:endTime,detail:{devtools:{dataType:"track-entry",trackGroup,track,color:resolvedColor,tooltipText:name,properties}}});}catch(e){console.warn("[CatalystPerf] measure failed:",name,e.message);}}marker(name,startTime,detail={},color=_constants.COLOR.SECONDARY){try{performance.mark(name,{startTime,detail:{devtools:{dataType:"marker",color,tooltipText:name,properties:this._properties(detail)}}});}catch(e){console.warn("[CatalystPerf] marker failed:",name,e.message);}}_properties(detail){return Object.entries(detail).filter(([key,value])=>!INTERNAL_PROPERTIES.has(key)&&value!=null).sort(([left],[right])=>(PROPERTY_PRIORITY[left]??100)-(PROPERTY_PRIORITY[right]??100)).map(([key,value])=>[this._propertyName(key),this._propertyValue(value)]);}_propertyName(key){if(PROPERTY_LABELS[key])return PROPERTY_LABELS[key];return key.replace(/([a-z0-9])([A-Z])/g,"$1 $2").replace(/[._-]+/g," ").replace(/^./,char=>char.toUpperCase());}_propertyValue(value){if(typeof value==="object"){try{return JSON.stringify(value);}catch{return String(value);}}return String(value);}// ─── Color derivation ─────────────────────────────────────────────────────
|
|
28
|
+
_colorFor(name,detail,startTime,endTime){const duration=endTime-startTime;// LoAF — always error (it's a bad frame by definition)
|
|
29
|
+
if(name.startsWith(_constants.PREFIX.LOAF))return _constants.COLOR.ERROR;// LCP — error if slow
|
|
30
|
+
if(name.startsWith(_constants.PREFIX.LCP))return detail.renderTime>_constants.THRESHOLD.LCP_BAD_MS?_constants.COLOR.ERROR:_constants.COLOR.PRIMARY;if(name.startsWith(_constants.PREFIX.FCP))return _constants.COLOR.PRIMARY;// Navigation session
|
|
31
|
+
if(name.startsWith(_constants.PREFIX.SESSION_NAV))return duration>_constants.THRESHOLD.NAV_SESSION_BAD_MS?_constants.COLOR.ERROR:_constants.COLOR.PRIMARY;// Route transition
|
|
32
|
+
if(name.startsWith(_constants.PREFIX.ROUTE_TRANSITION))return _constants.COLOR.SECONDARY;// Scroll session — error if janky
|
|
33
|
+
if(name.startsWith(_constants.PREFIX.SESSION_SCROLL))return detail.loafCount>0?_constants.COLOR.ERROR:_constants.COLOR.PRIMARY;// Keyboard / viewport — informational
|
|
34
|
+
if(name.startsWith(_constants.PREFIX.SESSION_KEYBOARD))return _constants.COLOR.SECONDARY;if(name.startsWith(_constants.PREFIX.VIEWPORT_RESIZE))return _constants.COLOR.SECONDARY;// Layout shift
|
|
35
|
+
if(name.startsWith(_constants.PREFIX.LAYOUT_SHIFT))return detail.value>_constants.THRESHOLD.CLS_BAD?_constants.COLOR.ERROR:_constants.COLOR.TERTIARY;// Cache spans — color passed explicitly by CacheCollector, this is fallback only
|
|
36
|
+
if(name.startsWith(_constants.PREFIX.CACHE+"/")){if(name.includes("/hit|memory|"))return _constants.COLOR.PRIMARY;if(name.includes("/hit|disk|"))return _constants.COLOR.PRIMARY_LIGHT;if(name.includes("/miss|"))return _constants.COLOR.ERROR;return _constants.COLOR.TERTIARY;}// Browser network calls
|
|
37
|
+
if(name.startsWith(_constants.PREFIX.NETWORK_TIMING)){if(detail.ok===false||duration>_constants.THRESHOLD.FETCH_SLOW_MS)return _constants.COLOR.ERROR;return _constants.COLOR.SECONDARY;}// Hook spans — error if LoAF burst detected during mount
|
|
38
|
+
if(name.startsWith(_constants.PREFIX.HOOK_MOUNT)||name.startsWith(_constants.PREFIX.HOOK_LAZY))return detail.loafCount>0?_constants.COLOR.ERROR:_constants.COLOR.SECONDARY;// Interaction
|
|
39
|
+
if(name.startsWith(_constants.PREFIX.INTERACTION)||Number.isFinite(detail.responseMs))return(detail.responseMs??duration)>_constants.THRESHOLD.INTERACTION_SLOW_MS?_constants.COLOR.ERROR:_constants.COLOR.PRIMARY;// Long press — just informational
|
|
40
|
+
if(name.startsWith(_constants.PREFIX.LONG_PRESS))return _constants.COLOR.TERTIARY;return _constants.COLOR.SECONDARY;}}exports.Measure=Measure;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.Session=void 0;/**
|
|
2
|
+
* session.js
|
|
3
|
+
*
|
|
4
|
+
* Reusable open/close session pattern.
|
|
5
|
+
* A session is a named span that stays open until explicitly closed.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const session = new Session('catalyst:session/scroll', measure, TRACK.SCROLL)
|
|
9
|
+
* session.open({ loafCount: 0 })
|
|
10
|
+
* session.close({ loafCount: 3, janky: true })
|
|
11
|
+
*
|
|
12
|
+
* The session owns its own start mark so callers don't track mark names.
|
|
13
|
+
*/class Session{/**
|
|
14
|
+
* @param {string} name - base measure name (catalyst:session/...)
|
|
15
|
+
* @param {Measure} measure - shared Measure instance
|
|
16
|
+
* @param {string} track - Chrome DevTools track name
|
|
17
|
+
*/constructor(name,measure,track){this._name=name;this._measure=measure;this._track=track;this._state=null;// { startMark, startTime, ...extra } | null
|
|
18
|
+
}get isOpen(){return this._state!==null;}get startTime(){return this._state?.startTime??null;}/**
|
|
19
|
+
* Open the session.
|
|
20
|
+
* @param {object} extra - any fields to carry in state (e.g. loafCount)
|
|
21
|
+
* @param {number} [atTime] - override start time (default: performance.now())
|
|
22
|
+
*/open(extra={},atTime=null){if(this._state)return;// already open — ignore double-open
|
|
23
|
+
const startTime=atTime??performance.now();const startMark=`${this._name}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._state={startMark,startTime,...extra};}/**
|
|
24
|
+
* Close the session and emit the measure.
|
|
25
|
+
* @param {object} detail - fields added to properties[] in DevTools
|
|
26
|
+
* @param {number} [atTime] - override end time (default: performance.now())
|
|
27
|
+
* @param {string} [color] - override color
|
|
28
|
+
*/close(detail={},atTime=null,color=null){if(!this._state)return;const{startMark,startTime}=this._state;const endTime=atTime??performance.now();this._state=null;this._measure.emit(this._name,startMark,endTime,{duration:Math.round(endTime-startTime),...detail},this._track,color);}/**
|
|
29
|
+
* Read a value stored in session state (e.g. loafCount).
|
|
30
|
+
*/get(key){return this._state?.[key];}/**
|
|
31
|
+
* Update a value in session state without closing.
|
|
32
|
+
*/set(key,value){if(this._state)this._state[key]=value;}/**
|
|
33
|
+
* Increment a numeric counter in session state.
|
|
34
|
+
*/increment(key){if(this._state)this._state[key]=(this._state[key]??0)+1;}}exports.Session=Session;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.PerfStore=void 0;var _waterfalls=require("./waterfalls.js");var _summary=require("./summary.js");var _traceExport=require("./traceExport.js");const MAX_RECORDS=5000;const CACHE_STATUS_BY_TYPE={"cache-hit-memory":"memory-hit","cache-hit-disk":"disk-hit","cache-miss-fetch":"miss","network-fetch-complete":"fetch"};const BOOT_LABEL_BY_TYPE={"boot-activity-created":"Activity created","boot-webview-constructed":"WebView constructed","boot-load-url":"loadUrl called","boot-page-started":"Page started","boot-page-finished":"Page finished"};function clone(value){return JSON.parse(JSON.stringify(value));}function round(value){return Number.isFinite(value)?Math.round(value):value;}function shortUrl(url){if(!url)return"";try{const parsed=new URL(url,window.location?.href);return parsed.pathname||parsed.host;}catch{return String(url).slice(0,80);}}function isFiniteNumber(value){return Number.isFinite(value);}class PerfStore{constructor(nativeToWeb){this._nativeToWeb=nativeToWeb??(time=>time);this._seq=0;this._pendingPageLoads=new Map();this._pendingHwAccel=null;this._pendingInteractions=new Map();this._data=this._createData();}expose(){if(typeof window==="undefined")return;window.__catalystPerfStore=this._data;const api={version:this._data.version,store:()=>this.snapshot(),events:()=>clone(this._data.events),requests:()=>clone(this._data.requests),sessions:()=>clone(this._data.sessions),metrics:()=>clone(this._data.metrics),insights:()=>clone(this._data.insights),waterfalls:()=>(0,_waterfalls.listWaterfalls)(),waterfall:(type="all")=>(0,_waterfalls.buildWaterfall)(this.snapshot(),type),summary:()=>(0,_summary.buildSummary)(this.snapshot()),trace:()=>JSON.stringify((0,_traceExport.buildChromeTrace)(this.snapshot())),downloadTrace:filename=>(0,_traceExport.downloadChromeTrace)(this.snapshot(),filename),export:()=>this.snapshot(),clear:()=>this.clear()};window.CatalystPerf=api;}snapshot(){return clone(this._data);}clear(){this._seq=0;this._data.createdAt=new Date().toISOString();this._data.events.length=0;this._data.requests.length=0;this._data.sessions.length=0;this._data.metrics.length=0;this._data.insights.length=0;this._pendingPageLoads.clear();this._pendingInteractions.clear();this._pendingHwAccel=null;}recordNativeEvent(event,source="native"){if(!event?.type)return;const time=this._nativeEventTime(event);if(event.type==="memory-snapshot"){this._recordNativeDerivedData(event,time);return;}this._add("events",{id:this._id("event"),type:event.type,kind:this._eventKind(event.type),label:this._nativeEventLabel(event),source,startTime:time,endTime:time+1,durationMs:event.durationMs??1,detail:{...event}});this._recordNativeDerivedData(event,time);}recordNavigation(data){if(!data)return;if(data.label==="LCP"||data.label==="FCP"){this._add("metrics",{id:this._id("metric"),kind:"paint",name:data.label,label:data.label,startTime:data.startTime??0,endTime:data.endTime??data.durationMs??0,durationMs:data.durationMs,value:data.durationMs,unit:"ms",detail:{...data}});return;}this._add("sessions",{id:this._id("session"),kind:"route",label:data.label??"Route transition",startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,detail:{...data}});}recordInteraction(data){if(!data)return;if(data.phase==="start"){this._pendingInteractions.set(data.sessionId,data);this._add("events",{id:this._id("event"),type:"interaction-start",kind:"interaction",label:data.target?`Tap ${data.target}`:"Tap",source:"web",startTime:data.startTime,endTime:data.startTime+1,durationMs:1,detail:{...data}});return;}if(data.phase==="end"){const start=this._pendingInteractions.get(data.sessionId);this._pendingInteractions.delete(data.sessionId);const startTime=start?.startTime??data.startTime;const endTime=data.endTime??startTime;this._add("sessions",{id:this._id("session"),kind:"interaction",sessionId:data.sessionId,label:data.target?`Tap ${data.target}`:"Interaction",startTime,endTime,durationMs:data.durationMs??round(endTime-startTime),target:data.target??start?.target??null,detail:{...start,...data}});}}recordNetwork(data){if(!data)return;this._add("requests",{id:this._id("request"),kind:"network",label:`${data.method??"GET"} ${shortUrl(data.url)}`,url:data.url,method:data.method,status:data.status,startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,sessionId:data.sessionId??null,interactionId:data.sessionId??null,detail:{...data}});}recordNativeApi(data){if(!data)return;this._add("requests",{id:this._id("request"),kind:"native-api",label:`Native execution: ${data.method??"API"}`,method:data.method,startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,sessionId:data.sessionId??null,interactionId:data.sessionId??null,detail:{...data}});}recordRender(data){if(!data||data.metric==="memory")return;this._add("metrics",{id:this._id("metric"),kind:data.metric,name:data.metric,label:this._renderLabel(data),startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,value:data.value??data.minFps??data.durationMs??null,unit:this._renderUnit(data.metric),context:data.sessionId??null,detail:{...data}});}recordHook(data){if(!data)return;this._add("sessions",{id:this._id("session"),kind:"hook",label:data.label??`Hook ${data.hookType??"work"}`,startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,detail:{...data}});}recordScroll(data){if(!data)return;this._add("sessions",{id:this._id("session"),kind:"scroll",label:data.janky?"Scroll jank":"Scroll",startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,detail:{...data}});}recordKeyboard(data){if(!data)return;if(data.metric){this.recordRender(data);return;}this._add("sessions",{id:this._id("session"),kind:"keyboard",label:"Keyboard",startTime:data.startTime,endTime:data.endTime,durationMs:data.durationMs,detail:{...data}});}recordInsight(data){if(!data)return;this._add("insights",{id:this._id("insight"),kind:"insight",severity:data.severity,rule:data.rule,label:data.title??data.rule,title:data.title??data.rule,category:data.category??"general",message:data.message,fix:data.fix,startTime:data.startTime,endTime:data.endTime??(data.startTime??0)+1,durationMs:data.durationMs??1,interactionId:data.interactionId??null,callId:data.callId??null,requestId:data.requestId??null,memoryId:data.memoryId??null,detail:{...data}});}_recordNativeDerivedData(event,time){const{type}=event;if(type==="cold-start"){this._add("metrics",{id:this._id("metric"),kind:"startup",name:"cold-start",label:`Cold start ${round(event.durationMs)}ms`,startTime:Math.max(0,time-(event.durationMs??0)),endTime:time,durationMs:event.durationMs,value:event.durationMs,unit:"ms",detail:{...event}});return;}if(type?.startsWith("boot-"))return;if(type==="page-load-start"){this._pendingPageLoads.set(event.url,{startTime:time,event});return;}if(type==="page-load-end"||type==="page-load-error"){const pending=this._pendingPageLoads.get(event.url);this._pendingPageLoads.delete(event.url);const durationMs=event.durationMs??null;const startTime=pending?.startTime??(durationMs!=null?Math.max(0,time-durationMs):time);this._add("sessions",{id:this._id("session"),kind:"page-load",label:event.url?`Page ${shortUrl(event.url)}`:"Page load",startTime,endTime:time,durationMs:durationMs??round(time-startTime),url:event.url,detail:{...(pending?.event??{}),...event}});return;}if(CACHE_STATUS_BY_TYPE[type]){this._recordCacheRequest(event,time);return;}if(type==="cache-summary"){this._add("metrics",{id:this._id("metric"),kind:"cache-summary",name:"cache-summary",label:`Cache ${event.hitRatePct??0}% hit`,startTime:time,endTime:time+1,durationMs:1,value:event.hitRatePct,unit:"%",detail:{...event}});return;}if(type==="memory-snapshot"){this._add("metrics",{id:this._id("metric"),kind:"memory",name:"memory",label:`Memory ${round(event.totalMb)}MB total`,startTime:time,endTime:time+1,durationMs:1,value:event.totalMb,unit:"MB",detail:{...event}});this._updateMemorySummary(event,time);return;}if(type==="hw-accel-change"){this._recordHwAccel(event,time);}}_recordCacheRequest(event,time){const durationMs=event.durationMs??0;const startTime=isFiniteNumber(event.nativeStartMs)?this._nativeToWeb(event.nativeStartMs):Math.max(0,time-durationMs);const endTime=startTime+durationMs;const cacheStatus=CACHE_STATUS_BY_TYPE[event.type];const label=`${cacheStatus} ${shortUrl(event.url)}`;this._add("requests",{id:this._id("request"),kind:"cache",label,url:event.url,status:event.statusCode??null,startTime,endTime,durationMs,source:event.type.includes("memory")?"memory":event.type.includes("disk")?"disk":event.type==="network-fetch-complete"?"network":"cache",cacheStatus,resourceType:event.resourceType??"other",detail:{...event}});}_recordHwAccel(event,time){if(event.state==="off"){this._pendingHwAccel={startTime:time,event};return;}if(event.state!=="on"||!this._pendingHwAccel)return;const start=this._pendingHwAccel;this._pendingHwAccel=null;this._add("sessions",{id:this._id("session"),kind:"hw-accel",label:"Software render window",startTime:start.startTime,endTime:time,durationMs:round(time-start.startTime),detail:{...start.event,restored:event}});}_updateMemorySummary(event,time){let summary=this._data.metrics.find(metric=>metric.kind==="memory-summary");if(!summary){summary=this._add("metrics",{id:"metric-memory-summary",kind:"memory-summary",name:"memory-summary",label:"Memory summary",startTime:time,endTime:time,durationMs:1,value:event.totalMb,unit:"MB",detail:{sampleCount:0,firstTime:time,lastTime:time,latest:{},peak:{},min:{},max:{}}});}const detail=summary.detail;const latest={jvmMb:event.jvmMb,webviewMb:event.webviewMb,otherMb:event.otherMb,totalMb:event.totalMb,peakMb:event.peakMb,coldStartMs:event.coldStartMs??null,thread:event.thread??null};const fields=["jvmMb","webviewMb","otherMb","totalMb","peakMb"];detail.sampleCount+=1;detail.lastTime=time;detail.latest=latest;for(const field of fields){const value=latest[field];if(!Number.isFinite(value))continue;detail.min[field]=Number.isFinite(detail.min[field])?Math.min(detail.min[field],value):value;detail.max[field]=Number.isFinite(detail.max[field])?Math.max(detail.max[field],value):value;detail.peak[field]=detail.max[field];}summary.label=`Memory ${round(event.totalMb)}MB total`;summary.startTime=detail.firstTime;summary.endTime=time;summary.durationMs=Math.max(1,round(time-detail.firstTime));summary.value=event.totalMb;}_nativeEventTime(event){if(isFiniteNumber(event.nativeTime))return this._nativeToWeb(event.nativeTime);if(isFiniteNumber(event.nativeEndMs))return this._nativeToWeb(event.nativeEndMs);if(isFiniteNumber(event.endNativeTime))return this._nativeToWeb(event.endNativeTime);if(isFiniteNumber(event.nativeStartMs))return this._nativeToWeb(event.nativeStartMs);if(isFiniteNumber(event.startNativeTime))return this._nativeToWeb(event.startNativeTime);return performance.now();}_nativeEventLabel(event){if(BOOT_LABEL_BY_TYPE[event.type])return BOOT_LABEL_BY_TYPE[event.type];if(event.type==="page-load-start")return`Page started ${shortUrl(event.url)}`;if(event.type==="page-load-end")return`Page finished ${shortUrl(event.url)}`;if(event.type==="page-load-error")return`Page error ${shortUrl(event.url)}`;if(CACHE_STATUS_BY_TYPE[event.type])return`${CACHE_STATUS_BY_TYPE[event.type]} ${shortUrl(event.url)}`;return event.type;}_eventKind(type){if(type?.startsWith("boot-")||type==="cold-start")return"startup";if(type?.startsWith("page-load"))return"page-load";if(type?.startsWith("cache-")||type==="network-fetch-complete")return"cache";if(type?.startsWith("keyboard"))return"keyboard";if(type?.startsWith("scroll"))return"scroll";if(type==="fps-drop-episode"||type==="long-task")return"render";return"event";}_renderLabel(data){if(data.metric==="fps-drop")return`FPS drop min ${data.minFps?.toFixed?.(1)??"?"}`;if(data.metric==="long-task")return`Long task ${round(data.durationMs)}ms`;if(data.metric==="loaf")return`LoAF ${round(data.durationMs)}ms`;if(data.metric==="layout-shift")return`Layout shift ${data.value?.toFixed?.(3)??"?"}`;return data.metric;}_renderUnit(metric){if(metric==="fps-drop")return"fps";if(metric==="layout-shift")return"score";return"ms";}_add(bucket,record){this._data[bucket].push(record);if(this._data[bucket].length>MAX_RECORDS){this._data[bucket].shift();}return record;}_id(prefix){this._seq+=1;return`${prefix}-${this._seq}`;}_createData(){return{version:1,createdAt:new Date().toISOString(),events:[],requests:[],sessions:[],metrics:[],insights:[]};}}exports.PerfStore=PerfStore;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.buildSummary=buildSummary;const SLOW_MS={interaction:300,request:500,nativeApi:150,render:50};function round(value){return Number.isFinite(value)?Math.round(value):value;}function durationOf(record){if(Number.isFinite(record?.durationMs))return record.durationMs;if(Number.isFinite(record?.startTime)&&Number.isFinite(record?.endTime)){return Math.max(0,record.endTime-record.startTime);}return 0;}function byDurationDesc(a,b){return durationOf(b)-durationOf(a);}function overlaps(record,start,end,padding=0){if(!Number.isFinite(start)||!Number.isFinite(end))return false;const recordStart=record.startTime??0;const recordEnd=record.endTime??recordStart;return recordStart<end+padding&&recordEnd>=start-padding;}function top(records,count=5){return[...records].sort(byDurationDesc).slice(0,count);}function countBy(records,key){return records.reduce((acc,record)=>{const value=record?.[key]??"unknown";acc[value]=(acc[value]??0)+1;return acc;},{});}function summarizeInteraction(interaction,data){const start=interaction.startTime;const end=interaction.endTime;const sessionId=interaction.sessionId??interaction.detail?.sessionId;const related=(record,padding=150)=>{const detail=record.detail??{};return detail.sessionId===sessionId||detail.interactionId===sessionId||record.sessionId===sessionId||record.interactionId===sessionId||overlaps(record,start,end,padding);};const requests=data.requests.filter(request=>related(request));const render=data.metrics.filter(metric=>["fps-drop","long-task","loaf","layout-shift"].includes(metric.kind)).filter(metric=>related(metric,300));const insights=data.insights.filter(insight=>related(insight,300));const nativeApiTime=requests.filter(request=>request.kind==="native-api").reduce((total,request)=>total+durationOf(request),0);const networkTime=requests.filter(request=>request.kind==="network").reduce((total,request)=>total+durationOf(request),0);const slowestRequest=top(requests,1)[0]??null;return{id:interaction.id,label:interaction.label,target:interaction.target??interaction.detail?.target??null,startTime:round(start),durationMs:round(durationOf(interaction)),requestCount:requests.length,renderIssueCount:render.length,insightCount:insights.length,nativeApiTimeMs:round(nativeApiTime),networkTimeMs:round(networkTime),slowestRequest:slowestRequest?{id:slowestRequest.id,kind:slowestRequest.kind,label:slowestRequest.label,durationMs:round(durationOf(slowestRequest))}:null,severity:durationOf(interaction)>=SLOW_MS.interaction||render.length>0||insights.length>0?"warning":"ok"};}function buildFindings(data,interactionSummaries){const findings=[];const slowInteractions=interactionSummaries.filter(interaction=>interaction.durationMs>=SLOW_MS.interaction);const slowRequests=data.requests.filter(request=>durationOf(request)>=SLOW_MS.request);const slowNativeApi=data.requests.filter(request=>request.kind==="native-api"&&durationOf(request)>=SLOW_MS.nativeApi);const renderIssues=data.metrics.filter(metric=>["fps-drop","long-task","loaf","layout-shift"].includes(metric.kind));const cacheMisses=data.requests.filter(request=>request.cacheStatus==="miss");if(slowInteractions.length>0){findings.push({severity:"warning",title:`${slowInteractions.length} slow interaction${slowInteractions.length===1?"":"s"}`,detail:`${slowInteractions[0].label} took ${slowInteractions[0].durationMs}ms`});}if(slowRequests.length>0){const slowest=top(slowRequests,1)[0];findings.push({severity:"warning",title:`${slowRequests.length} slow request${slowRequests.length===1?"":"s"}`,detail:`${slowest.label} took ${round(durationOf(slowest))}ms`});}if(slowNativeApi.length>0){const slowest=top(slowNativeApi,1)[0];findings.push({severity:"warning",title:`${slowNativeApi.length} slow native call${slowNativeApi.length===1?"":"s"}`,detail:`${slowest.label} took ${round(durationOf(slowest))}ms`});}if(renderIssues.length>0){findings.push({severity:"warning",title:`${renderIssues.length} render issue${renderIssues.length===1?"":"s"}`,detail:renderIssues[0].label});}if(cacheMisses.length>0){findings.push({severity:"info",title:`${cacheMisses.length} cache miss${cacheMisses.length===1?"":"es"}`,detail:cacheMisses[0].label});}return findings;}function buildSummary(data){const sessions=data.sessions??[];const requests=data.requests??[];const metrics=data.metrics??[];const events=data.events??[];const insights=data.insights??[];const interactions=sessions.filter(session=>session.kind==="interaction");const renderIssues=metrics.filter(metric=>["fps-drop","long-task","loaf","layout-shift"].includes(metric.kind));const memory=metrics.find(metric=>metric.kind==="memory-summary")??null;const interactionSummaries=top(interactions,10).map(interaction=>summarizeInteraction(interaction,data));return{generatedAt:new Date().toISOString(),counts:{events:events.length,sessions:sessions.length,requests:requests.length,metrics:metrics.length,insights:insights.length},byKind:{sessions:countBy(sessions,"kind"),requests:countBy(requests,"kind"),metrics:countBy(metrics,"kind")},slowest:{interactions:top(interactions).map(record=>({id:record.id,label:record.label,durationMs:round(durationOf(record))})),requests:top(requests).map(record=>({id:record.id,kind:record.kind,label:record.label,durationMs:round(durationOf(record))})),render:top(renderIssues).map(record=>({id:record.id,kind:record.kind,label:record.label,durationMs:round(durationOf(record))}))},interactions:interactionSummaries,memory:memory?{label:memory.label,latest:memory.detail?.latest??{},peak:memory.detail?.peak??{},sampleCount:memory.detail?.sampleCount??0}:null,findings:buildFindings(data,interactionSummaries)};}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.buildChromeTrace=buildChromeTrace;exports.downloadChromeTrace=downloadChromeTrace;const THREADS={navigation:1,input:2,render:3,memory:4,network:5,nativeApi:6,insights:7,events:8};const THREAD_NAMES={[THREADS.navigation]:"Navigation",[THREADS.input]:"Input",[THREADS.render]:"Render",[THREADS.memory]:"Memory",[THREADS.network]:"Network / Cache",[THREADS.nativeApi]:"Native API execution",[THREADS.insights]:"Insights",[THREADS.events]:"Events"};function us(ms){return Math.max(0,Math.round((Number(ms)||0)*1000));}function durationMs(record){if(Number.isFinite(record.durationMs))return Math.max(0.001,record.durationMs);if(Number.isFinite(record.startTime)&&Number.isFinite(record.endTime)){return Math.max(0.001,record.endTime-record.startTime);}return 0.001;}function label(record,fallback){return record.label??record.title??record.name??record.type??record.kind??fallback;}function detail(record){const source=record.detail??record;const relatedTap=record.interactionId??record.sessionId??source.interactionId??source.sessionId;return relatedTap?{relatedTap,...source}:source;}function displayLabel(record,fallback){const base=label(record,fallback);const source=record.detail??record;const relatedTap=record.interactionId??record.sessionId??source.interactionId??source.sessionId;if(!relatedTap)return base;if(record.kind==="interaction")return`${relatedTap}: ${record.target??source.target??base}`;return base.startsWith(`[${relatedTap}]`)?base:`[${relatedTap}] ${base}`;}function sessionThread(kind){if(kind==="page-load"||kind==="route")return THREADS.navigation;if(kind==="scroll"||kind==="keyboard"||kind==="interaction")return THREADS.input;if(kind==="hw-accel"||kind==="hook")return THREADS.render;return THREADS.events;}function requestThread(kind){if(kind==="native-api")return THREADS.nativeApi;return THREADS.network;}function metricThread(kind){if(kind==="memory"||kind==="memory-summary")return THREADS.memory;if(kind==="startup"||kind==="paint")return THREADS.navigation;return THREADS.render;}function addMetadata(events,pid){events.push({ph:"M",pid,tid:0,name:"process_name",args:{name:"Catalyst"}});for(const[tid,name]of Object.entries(THREAD_NAMES)){events.push({ph:"M",pid,tid:Number(tid),name:"thread_name",args:{name}});}}function addDuration(events,record,tid,category,fallbackName){const startTime=record.startTime??record.time??0;events.push({name:displayLabel(record,fallbackName),cat:category,ph:"X",ts:us(startTime),dur:us(durationMs(record)),pid:1,tid,args:detail(record)});}function addInstant(events,record,tid,category,fallbackName){events.push({name:displayLabel(record,fallbackName),cat:category,ph:"i",s:"t",ts:us(record.startTime??record.time??0),pid:1,tid,args:detail(record)});}function addTapMarker(events,session){const source=session.detail??session;const tap=session.interactionId??session.sessionId??source.interactionId??source.sessionId;if(!tap)return;events.push({name:`${tap}: ${session.target??source.target??"interaction"}`,cat:"Catalyst Interaction",ph:"i",s:"g",ts:us(session.startTime??0),pid:1,tid:THREADS.input,args:{relatedTap:tap,target:session.target??source.target??"unknown"}});}function addCounter(events,record){const args={};const source=record.detail??record;for(const key of["jvmMb","webviewMb","nativeMb","otherMb","totalMb","peakMb"]){if(Number.isFinite(source[key]))args[key]=source[key];}if(Object.keys(args).length===0&&Number.isFinite(record.value)){args.value=record.value;}events.push({name:label(record,"Memory"),cat:"Catalyst Memory",ph:"C",ts:us(record.startTime??record.time??0),pid:1,tid:THREADS.memory,args});}function buildChromeTrace(data){const traceEvents=[];addMetadata(traceEvents,1);for(const session of data.sessions??[]){addDuration(traceEvents,session,sessionThread(session.kind),`Catalyst ${session.kind}`,"Session");if(session.kind==="interaction")addTapMarker(traceEvents,session);}for(const request of data.requests??[]){addDuration(traceEvents,request,requestThread(request.kind),`Catalyst ${request.kind}`,"Request");}const hasMemorySamples=(data.metrics??[]).some(metric=>metric.kind==="memory");for(const metric of data.metrics??[]){if(metric.kind==="memory-summary"&&hasMemorySamples)continue;if(metric.kind==="memory"||metric.kind==="memory-summary"){addCounter(traceEvents,metric);}else{addDuration(traceEvents,metric,metricThread(metric.kind),`Catalyst ${metric.kind}`,"Metric");}}for(const event of data.events??[]){addInstant(traceEvents,event,THREADS.events,`Catalyst ${event.kind??"event"}`,"Event");}for(const insight of data.insights??[]){addInstant(traceEvents,insight,THREADS.insights,"Catalyst Insights","Insight");}return{traceEvents:traceEvents.sort((a,b)=>(a.ts??0)-(b.ts??0)),metadata:{source:"CatalystPerf",createdAt:new Date().toISOString(),storeCreatedAt:data.createdAt??null,version:data.version??1}};}function downloadChromeTrace(data,filename){const trace=buildChromeTrace(data);const traceJson=JSON.stringify(trace);const resolvedFilename=filename??`catalyst-trace-${new Date().toISOString().replace(/[:.]/g,"-")}.json`;const eventCount=trace.traceEvents.length;const isIOSNative=typeof window!=="undefined"&&!!window.webkit?.messageHandlers?.NativeBridge;const isAndroidNative=typeof window!=="undefined"&&!!window.NativeBridge;if(typeof console!=="undefined"){console.info(`[CatalystPerf] Trace ready: ${resolvedFilename} (${eventCount} events)`);}if(isIOSNative){window.webkit.messageHandlers.NativeBridge.postMessage({command:"exportCatalystTrace",data:{filename:resolvedFilename,trace:traceJson}});if(typeof console!=="undefined"){console.info(`[CatalystPerf] Sent ${resolvedFilename} to the iOS share sheet.`);}return;}if(isAndroidNative){return;}if(typeof document==="undefined"||typeof Blob==="undefined"||typeof URL==="undefined"){return;}const link=document.createElement("a");if(!("download"in link)){if(typeof console!=="undefined"){console.warn("[CatalystPerf] Browser does not support blob downloads. Use CatalystPerf.trace() instead.");}return;}const blob=new Blob([traceJson],{type:"application/json"});const url=URL.createObjectURL(blob);link.href=url;link.download=resolvedFilename;document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);if(typeof console!=="undefined"){console.info(`[CatalystPerf] Download started. Check your browser Downloads for ${resolvedFilename}.`);}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.buildWaterfall=buildWaterfall;exports.listWaterfalls=listWaterfalls;const REQUEST_LANES=new Set(["network","cache","native-api"]);function durationOf(row){if(Number.isFinite(row.durationMs))return row.durationMs;if(Number.isFinite(row.startTime)&&Number.isFinite(row.endTime)){return Math.max(0,Math.round(row.endTime-row.startTime));}return null;}function toRow(record,lane,group=null){const startTime=record.startTime??record.time??0;const endTime=record.endTime??startTime;return{id:record.id,lane,group,kind:record.kind??record.type??lane,label:record.label??record.name??record.type??record.kind??lane,startTime,endTime,durationMs:durationOf({...record,startTime,endTime}),severity:record.severity??null,detail:record.detail??record};}function byStartTime(a,b){return(a.startTime??0)-(b.startTime??0);}function overlaps(row,start,end){if(!Number.isFinite(start)||!Number.isFinite(end))return false;const rowStart=row.startTime??0;const rowEnd=row.endTime??rowStart;return rowStart<end&&rowEnd>=start;}function isStandaloneEvent(event){return event.kind==="startup"||event.type==="navigation-back"||event.type==="page-load-error";}function isTimelineMetric(metric){return metric.kind!=="memory"&&metric.kind!=="memory-summary";}function rowsForWindow(data,start,end,group){return[...data.sessions.map(session=>toRow(session,session.kind,group)),...data.requests.map(request=>toRow(request,request.kind,group)),...data.metrics.filter(isTimelineMetric).map(metric=>toRow(metric,metric.kind,group)),...data.events.filter(isStandaloneEvent).map(event=>toRow(event,event.kind??"event",group)),...data.insights.map(insight=>toRow(insight,"insight",group))].filter(row=>overlaps(row,start,end)).sort(byStartTime);}function buildPageLoadWaterfall(data){const pageLoads=data.sessions.filter(session=>session.kind==="page-load");if(pageLoads.length===0){return[...data.events.filter(event=>event.kind==="startup"||event.type?.startsWith("boot-")).map(event=>toRow(event,"startup","page-load")),...data.metrics.filter(metric=>metric.kind==="paint"||metric.kind==="startup").map(metric=>toRow(metric,metric.kind,"page-load"))].sort(byStartTime);}return pageLoads.flatMap((pageLoad,index)=>{const group=pageLoad.label;const startupRows=index===0?data.events.filter(event=>event.kind==="startup"&&(event.startTime??0)<=pageLoad.endTime).map(event=>toRow(event,"startup",group)):[];return[...startupRows,toRow(pageLoad,"page-load",group),...rowsForWindow(data,pageLoad.startTime,pageLoad.endTime,group).filter(row=>row.id!==pageLoad.id)];}).sort(byStartTime);}function buildRouteWaterfall(data){const routes=data.sessions.filter(session=>session.kind==="route");return routes.flatMap(route=>[toRow(route,"route",route.label),...rowsForWindow(data,route.startTime,route.endTime+1000,route.label).filter(row=>row.id!==route.id)]).sort(byStartTime);}function buildRequestWaterfall(data){return data.requests.filter(request=>REQUEST_LANES.has(request.kind)).map(request=>toRow(request,request.kind,request.sessionId??request.navigationId??null)).sort(byStartTime);}function buildNativeApiWaterfall(data){return data.requests.filter(request=>request.kind==="native-api").map(request=>toRow(request,request.kind,request.sessionId??null)).sort(byStartTime);}function buildInteractionWaterfall(data){const interactions=data.sessions.filter(session=>session.kind==="interaction");return interactions.flatMap(interaction=>{const group=interaction.label;const matchingSession=row=>{const detail=row.detail??{};return detail.sessionId===interaction.sessionId||detail.interactionId===interaction.sessionId||overlaps(row,interaction.startTime,interaction.endTime);};return[toRow(interaction,"interaction",group),...[...data.requests.map(request=>toRow(request,request.kind,group)),...data.metrics.filter(isTimelineMetric).map(metric=>toRow(metric,metric.kind,group)),...data.insights.map(insight=>toRow(insight,"insight",group))].filter(row=>row.id!==interaction.id&&matchingSession(row))];}).sort(byStartTime);}function buildRenderJankWaterfall(data){const renderKinds=new Set(["fps-drop","long-task","loaf","layout-shift","hw-accel"]);return[...data.metrics.filter(metric=>renderKinds.has(metric.kind)).map(metric=>toRow(metric,"render",metric.context??null)),...data.sessions.filter(session=>session.kind==="hw-accel").map(session=>toRow(session,"render",session.detail?.trigger??null))].sort(byStartTime);}function buildScrollWaterfall(data){const scrolls=data.sessions.filter(session=>session.kind==="scroll");return scrolls.flatMap(scroll=>[toRow(scroll,"scroll",scroll.label),...rowsForWindow(data,scroll.startTime,scroll.endTime,scroll.label).filter(row=>row.id!==scroll.id&&["fps-drop","long-task","loaf","layout-shift","insight"].includes(row.kind))]).sort(byStartTime);}function buildKeyboardWaterfall(data){const keyboards=data.sessions.filter(session=>session.kind==="keyboard");return keyboards.flatMap(keyboard=>[toRow(keyboard,"keyboard",keyboard.label),...rowsForWindow(data,keyboard.startTime,keyboard.endTime,keyboard.label).filter(row=>row.id!==keyboard.id&&["viewport-resize","scroll","layout-shift","insight"].includes(row.kind))]).sort(byStartTime);}function buildCacheWaterfall(data){return[...data.requests.filter(request=>request.kind==="cache").map(request=>toRow(request,"cache",request.cacheStatus??null)),...data.metrics.filter(metric=>metric.kind==="cache-summary").map(metric=>toRow(metric,"cache-summary"))].sort(byStartTime);}function buildMemoryTimeline(data){return data.metrics.filter(metric=>metric.kind==="memory"||metric.kind==="memory-summary").map(metric=>toRow(metric,"memory")).sort(byStartTime);}function buildAllWaterfall(data){return[...data.sessions.map(session=>toRow(session,session.kind)),...data.requests.map(request=>toRow(request,request.kind)),...data.metrics.filter(isTimelineMetric).map(metric=>toRow(metric,metric.kind)),...data.events.filter(isStandaloneEvent).map(event=>toRow(event,event.kind??"event")),...data.insights.map(insight=>toRow(insight,"insight"))].sort(byStartTime);}function buildWaterfall(data,type="all"){switch(type){case"page-load":case"page":return buildPageLoadWaterfall(data);case"route":case"navigation":return buildRouteWaterfall(data);case"requests":case"request":return buildRequestWaterfall(data);case"native-bridge":case"bridge":case"api":case"native-api":return buildNativeApiWaterfall(data);case"interactions":case"interaction":return buildInteractionWaterfall(data);case"render":case"jank":case"render-jank":return buildRenderJankWaterfall(data);case"scroll":return buildScrollWaterfall(data);case"keyboard":case"input":return buildKeyboardWaterfall(data);case"cache":return buildCacheWaterfall(data);case"memory":return buildMemoryTimeline(data);case"all":default:return buildAllWaterfall(data);}}function listWaterfalls(){return["page-load","navigation","requests","native-api","interactions","render-jank","scroll","keyboard","cache","memory","all"];}
|