catalyst-core 0.2.0-beta.2 → 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.
Files changed (47) hide show
  1. package/dist/native/androidProject/app/build.gradle.kts +1 -0
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +143 -4
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +26 -6
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MetricsMonitor.kt +143 -17
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +6 -0
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +26 -0
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
  10. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
  11. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
  12. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
  13. package/dist/native/bridge/WebBridge.js +2 -2
  14. package/dist/native/bridge/constants/NativeInterfaces.js +2 -1
  15. package/dist/native/bridge/perf/collectors/ApiCollector.js +22 -0
  16. package/dist/native/bridge/perf/collectors/CacheCollector.js +40 -0
  17. package/dist/native/bridge/perf/collectors/HookCollector.js +43 -0
  18. package/dist/native/bridge/perf/collectors/InsightsCollector.js +47 -0
  19. package/dist/native/bridge/perf/collectors/InteractionCollector.js +38 -0
  20. package/dist/native/bridge/perf/collectors/KeyboardCollector.js +25 -0
  21. package/dist/native/bridge/perf/collectors/NavigationCollector.js +53 -0
  22. package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +7 -0
  23. package/dist/native/bridge/perf/collectors/RenderCollector.js +40 -0
  24. package/dist/native/bridge/perf/collectors/ScrollCollector.js +25 -0
  25. package/dist/native/bridge/perf/collectors/ThreadCollector.js +27 -0
  26. package/dist/native/bridge/perf/core/constants.js +43 -0
  27. package/dist/native/bridge/perf/core/measure.js +40 -0
  28. package/dist/native/bridge/perf/core/session.js +34 -0
  29. package/dist/native/bridge/perf/core/store.js +1 -0
  30. package/dist/native/bridge/perf/core/summary.js +1 -0
  31. package/dist/native/bridge/perf/core/traceExport.js +1 -0
  32. package/dist/native/bridge/perf/core/waterfalls.js +1 -0
  33. package/dist/native/bridge/perf/index.js +81 -0
  34. package/dist/native/buildIos/config.js +1 -1
  35. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
  36. package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
  37. package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
  38. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +10 -0
  39. package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
  40. package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
  41. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
  42. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
  43. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
  44. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +126 -0
  45. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +75 -1
  46. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
  47. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
- "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _NativeInterfaces=require("./constants/NativeInterfaces.js");var _NativeBridge=_interopRequireDefault(require("./utils/NativeBridge.js"));var _CameraUtils=_interopRequireDefault(require("./utils/CameraUtils.js"));var _PluginBridge=_interopRequireDefault(require("../plugin-bridge/PluginBridge.js"));function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e};}const DEVICE_INFO_PLUGIN={pluginId:"io.catalyst.device_info",command:"getDeviceInfo",successEvent:"onSuccess",errorEvent:"onError"};const DEVICE_INFO_REQUEST_TIMEOUT_MS=5000;const DEVICE_INFO_PLUGIN_FALLBACK_CODES=new Set(["PLUGIN_NOT_FOUND","COMMAND_NOT_SUPPORTED","PLUGIN_NOT_REGISTERED"]);const DEVICE_INFO_PLUGIN_FALLBACK_MESSAGES=["pluginbridge is not available in this environment","pluginbridge is not available","device info plugin request timed out"];const normalizeDeviceInfoPayload=payload=>{if(typeof payload!=="string"){return payload;}try{return JSON.parse(payload);}catch{return{message:payload};}};const createDeviceInfoError=(payload,fallbackMessage)=>{const parsed=normalizeDeviceInfoPayload(payload);const message=parsed?.message||parsed?.error||fallbackMessage;const error=new Error(message);if(parsed?.code){error.code=parsed.code;}if(parsed?.pluginId){error.pluginId=parsed.pluginId;}if(parsed?.command){error.command=parsed.command;}return error;};class WebBridge{constructor(){this.handlers=new Map();this.initialized=false;this._logInitialization();}/**
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.default=void 0;var _NativeInterfaces=require("./constants/NativeInterfaces.js");var _NativeBridge=_interopRequireDefault(require("./utils/NativeBridge.js"));var _CameraUtils=_interopRequireDefault(require("./utils/CameraUtils.js"));var _PluginBridge=_interopRequireDefault(require("../plugin-bridge/PluginBridge.js"));function _interopRequireDefault(e){return e&&e.__esModule?e:{default:e};}function _interopRequireWildcard(e,t){if("function"==typeof WeakMap)var r=new WeakMap(),n=new WeakMap();return(_interopRequireWildcard=function(e,t){if(!t&&e&&e.__esModule)return e;var o,i,f={__proto__:null,default:e};if(null===e||"object"!=typeof e&&"function"!=typeof e)return f;if(o=t?n:r){if(o.has(e))return o.get(e);o.set(e,f);}for(const t in e)"default"!==t&&{}.hasOwnProperty.call(e,t)&&((i=(o=Object.defineProperty)&&Object.getOwnPropertyDescriptor(e,t))&&(i.get||i.set)?o(f,t,i):f[t]=e[t]);return f;})(e,t);}const DEVICE_INFO_PLUGIN={pluginId:"io.catalyst.device_info",command:"getDeviceInfo",successEvent:"onSuccess",errorEvent:"onError"};const DEVICE_INFO_REQUEST_TIMEOUT_MS=5000;const DEVICE_INFO_PLUGIN_FALLBACK_CODES=new Set(["PLUGIN_NOT_FOUND","COMMAND_NOT_SUPPORTED","PLUGIN_NOT_REGISTERED"]);const DEVICE_INFO_PLUGIN_FALLBACK_MESSAGES=["pluginbridge is not available in this environment","pluginbridge is not available","device info plugin request timed out"];const normalizeDeviceInfoPayload=payload=>{if(typeof payload!=="string"){return payload;}try{return JSON.parse(payload);}catch{return{message:payload};}};const createDeviceInfoError=(payload,fallbackMessage)=>{const parsed=normalizeDeviceInfoPayload(payload);const message=parsed?.message||parsed?.error||fallbackMessage;const error=new Error(message);if(parsed?.code){error.code=parsed.code;}if(parsed?.pluginId){error.pluginId=parsed.pluginId;}if(parsed?.command){error.command=parsed.command;}return error;};class WebBridge{constructor(){this.handlers=new Map();this.initialized=false;this._logInitialization();}/**
2
2
  * Log initialization info
3
3
  */_logInitialization(){console.log("🌉 WebBridge initialized");console.log("🌉 Available callback interfaces:",_NativeInterfaces.ALL_CALLBACKS.length);if(process.env.NODE_ENV==="development"){(0,_NativeInterfaces.debugInterfaces)();}}/**
4
4
  * Static method to initialize WebBridge on window object
5
- */static init=()=>{if(typeof window==="undefined"){console.error("🌉 WebBridge cannot be initialized outside the browser!");return null;}if(window.WebBridge){console.warn("🌉 WebBridge already initialized!");return window.WebBridge;}const bridge=new WebBridge();window.WebBridge=bridge;bridge.initialized=true;const{platform}=_NativeBridge.default.getEnvironmentInfo();console.log("🌉 WebBridge created and attached to window");return{bridge,platform,getDeviceInfo:bridge.getDeviceInfo};};/**
5
+ */static init=()=>{if(typeof window==="undefined"){console.error("🌉 WebBridge cannot be initialized outside the browser!");return null;}if(window.WebBridge){console.warn("🌉 WebBridge already initialized!");return window.WebBridge;}const bridge=new WebBridge();window.WebBridge=bridge;bridge.initialized=true;const{platform}=_NativeBridge.default.getEnvironmentInfo();if(window.__CATALYST_PROFILER_ENABLED===true){Promise.resolve().then(()=>_interopRequireWildcard(require("./perf/index.js"))).then(({default:WebPerfCollector})=>{WebPerfCollector.init(bridge);});}console.log("🌉 WebBridge created and attached to window");return{bridge,platform,getDeviceInfo:bridge.getDeviceInfo};};/**
6
6
  * Main callback handler called by native platforms
7
7
  * @param {string} interfaceName - The callback interface name
8
8
  * @param {any} data - Data from native platform
@@ -25,7 +25,8 @@ ON_GOOGLE_SIGN_IN_SUCCESS:"ON_GOOGLE_SIGN_IN_SUCCESS",ON_GOOGLE_SIGN_IN_ERROR:"O
25
25
  NETWORK_STATUS_CHANGED:"NETWORK_STATUS_CHANGED",// Safe area callbacks
26
26
  ON_SAFE_AREA_INSETS_UPDATED:"ON_SAFE_AREA_INSETS_UPDATED",// Transition callbacks
27
27
  ON_TRANSITION_COMMITTED:"ON_TRANSITION_COMMITTED",ON_TRANSITION_CANCELLED:"ON_TRANSITION_CANCELLED",ON_TRANSITION_TIMEOUT:"ON_TRANSITION_TIMEOUT",// Security callbacks
28
- ON_SCREEN_SECURE_SET:"ON_SCREEN_SECURE_SET",ON_SCREEN_SECURE_STATUS:"ON_SCREEN_SECURE_STATUS",ON_SCREEN_SECURE_ERROR:"ON_SCREEN_SECURE_ERROR",ON_WEB_DATA_CLEARED:"ON_WEB_DATA_CLEARED",ON_WEB_DATA_CLEAR_ERROR:"ON_WEB_DATA_CLEAR_ERROR"};// Interface Categories for easier management
28
+ ON_SCREEN_SECURE_SET:"ON_SCREEN_SECURE_SET",ON_SCREEN_SECURE_STATUS:"ON_SCREEN_SECURE_STATUS",ON_SCREEN_SECURE_ERROR:"ON_SCREEN_SECURE_ERROR",ON_WEB_DATA_CLEARED:"ON_WEB_DATA_CLEARED",ON_WEB_DATA_CLEAR_ERROR:"ON_WEB_DATA_CLEAR_ERROR",// Perf telemetry callbacks (native → web)
29
+ CATALYST_PERF_EVENT:"CATALYST_PERF_EVENT"};// Interface Categories for easier management
29
30
  const INTERFACE_CATEGORIES=exports.INTERFACE_CATEGORIES={CAMERA:{commands:[NATIVE_COMMANDS.OPEN_CAMERA,NATIVE_COMMANDS.REQUEST_CAMERA_PERMISSION],callbacks:[NATIVE_CALLBACKS.CAMERA_PERMISSION_STATUS,NATIVE_CALLBACKS.ON_CAMERA_CAPTURE,NATIVE_CALLBACKS.ON_CAMERA_ERROR]},FILE:{commands:[NATIVE_COMMANDS.PICK_FILE,NATIVE_COMMANDS.OPEN_FILE_WITH_INTENT],callbacks:[NATIVE_CALLBACKS.ON_FILE_PICKED,NATIVE_CALLBACKS.ON_FILE_PICK_ERROR,NATIVE_CALLBACKS.ON_FILE_PICK_CANCELLED,NATIVE_CALLBACKS.ON_FILE_PICK_STATE_UPDATE]},INTENT:{commands:[NATIVE_COMMANDS.OPEN_FILE_WITH_INTENT],callbacks:[NATIVE_CALLBACKS.ON_INTENT_SUCCESS,NATIVE_CALLBACKS.ON_INTENT_ERROR,NATIVE_CALLBACKS.ON_INTENT_CANCELLED]},HAPTIC:{commands:[NATIVE_COMMANDS.REQUEST_HAPTIC_FEEDBACK],callbacks:[NATIVE_CALLBACKS.HAPTIC_FEEDBACK]},NOTIFICATION:{commands:[NATIVE_COMMANDS.SCHEDULE_LOCAL_NOTIFICATION,NATIVE_COMMANDS.CANCEL_LOCAL_NOTIFICATION,NATIVE_COMMANDS.REQUEST_NOTIFICATION_PERMISSION,NATIVE_COMMANDS.REGISTER_FOR_PUSH_NOTIFICATIONS,NATIVE_COMMANDS.UPDATE_BADGE_COUNT,NATIVE_COMMANDS.SUBSCRIBE_TO_TOPIC,NATIVE_COMMANDS.UNSUBSCRIBE_FROM_TOPIC,NATIVE_COMMANDS.GET_SUBSCRIBED_TOPICS],callbacks:[NATIVE_CALLBACKS.NOTIFICATION_PERMISSION_STATUS,NATIVE_CALLBACKS.LOCAL_NOTIFICATION_SCHEDULED,NATIVE_CALLBACKS.LOCAL_NOTIFICATION_CANCELLED,NATIVE_CALLBACKS.PUSH_NOTIFICATION_TOKEN,NATIVE_CALLBACKS.NOTIFICATION_RECEIVED,NATIVE_CALLBACKS.NOTIFICATION_ACTION_PERFORMED,NATIVE_CALLBACKS.NOTIFICATION_TAPPED,NATIVE_CALLBACKS.TOPIC_SUBSCRIPTION_RESULT,NATIVE_CALLBACKS.SUBSCRIBED_TOPICS_RESULT]},DEVICE_INFO:{commands:[NATIVE_COMMANDS.GET_DEVICE_INFO],callbacks:[NATIVE_CALLBACKS.ON_DEVICE_INFO_SUCCESS,NATIVE_CALLBACKS.ON_DEVICE_INFO_ERROR]},AUTH:{commands:[NATIVE_COMMANDS.GOOGLE_SIGN_IN],callbacks:[NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_SUCCESS,NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_ERROR,NATIVE_CALLBACKS.ON_GOOGLE_SIGN_IN_CANCELLED]},NETWORK:{commands:[NATIVE_COMMANDS.GET_NETWORK_STATUS],callbacks:[NATIVE_CALLBACKS.NETWORK_STATUS_CHANGED]},SECURITY:{commands:[NATIVE_COMMANDS.SET_SCREEN_SECURE,NATIVE_COMMANDS.GET_SCREEN_SECURE,NATIVE_COMMANDS.CLEAR_WEB_DATA],callbacks:[NATIVE_CALLBACKS.ON_SCREEN_SECURE_SET,NATIVE_CALLBACKS.ON_SCREEN_SECURE_ERROR,NATIVE_CALLBACKS.ON_WEB_DATA_CLEARED,NATIVE_CALLBACKS.ON_WEB_DATA_CLEAR_ERROR]},TRANSITION:{commands:[NATIVE_COMMANDS.START_TRANSITION,NATIVE_COMMANDS.COMMIT_TRANSITION,NATIVE_COMMANDS.CANCEL_TRANSITION],callbacks:[NATIVE_CALLBACKS.ON_TRANSITION_COMMITTED,NATIVE_CALLBACKS.ON_TRANSITION_CANCELLED,NATIVE_CALLBACKS.ON_TRANSITION_TIMEOUT]}};// All available interfaces (for validation)
30
31
  const ALL_COMMANDS=exports.ALL_COMMANDS=Object.values(NATIVE_COMMANDS);const ALL_CALLBACKS=exports.ALL_CALLBACKS=Object.values(NATIVE_CALLBACKS);const ALL_INTERFACES=exports.ALL_INTERFACES=[...ALL_COMMANDS,...ALL_CALLBACKS];// Permission status constants
31
32
  const PERMISSION_STATUS=exports.PERMISSION_STATUS={GRANTED:"GRANTED",DENIED:"DENIED",NOT_DETERMINED:"NOT_DETERMINED",RESTRICTED:"RESTRICTED"};// Haptic feedback types (matching Android implementation)
@@ -0,0 +1,22 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.ApiCollector=void 0;var _constants=require("../core/constants.js");/**
2
+ * ApiCollector.js
3
+ *
4
+ * Instruments native API call timing — how long each @JavascriptInterface
5
+ * method takes from native receipt until its immediate native handler work is
6
+ * dispatched. It does not measure JS-to-native transport, JS callback delivery,
7
+ * or UI completion.
8
+ *
9
+ * Native events received (via __catalystPerfBatch drain):
10
+ * api-call { callId, method, nativeStartMs, nativeEndMs, durationMs, thread }
11
+ *
12
+ * Spans emitted (one per call, on Catalyst > Native API track):
13
+ * catalyst:api/<method> — nativeStart → nativeEnd
14
+ * color: primary-dark (<50ms), secondary (<200ms), error (>200ms)
15
+ * properties: method, durationMs, callId, thread, simulatorValid: false
16
+ *
17
+ * Reading the track:
18
+ * Blue = fast native call (<50ms) — expected for sync APIs (getDeviceInfo, haptic)
19
+ * Grey = medium native call (<200ms) — native work worth inspecting
20
+ * Red = slow native call (>200ms) — native work worth inspecting
21
+ */class ApiCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;}init(){// No web-side observers — all events come from native buffer drain
22
+ }/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}setInteractionSource(fn){this._getInteraction=fn;}onBatchEvent(event){if(event.type!=="api-call")return;const{method,callId,nativeStartMs,nativeEndMs,durationMs,thread}=event;const startTime=this._nativeToWeb(nativeStartMs);const endTime=this._nativeToWeb(nativeEndMs);const interactionId=this._getInteraction?.()?.interactionIdForRange(startTime,endTime)??null;const color=durationMs>200?_constants.COLOR.ERROR:durationMs>50?_constants.COLOR.SECONDARY:_constants.COLOR.PRIMARY_DARK;const startMark=`${_constants.PREFIX.NATIVE_API}:${method}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._notifyWaterfall?.({method,durationMs,callId,thread:thread??null,startTime,endTime,sessionId:interactionId,parentOperation:`Native execution: ${method}`});this._measure.emit(`${interactionId?`[${interactionId}] `:""}Native API: ${method} - ${durationMs}ms`,startMark,endTime,{interactionId,parentOperation:`Native execution: ${method}`,method,durationMs,callId,thread:thread??null,simulatorValid:false},_constants.TRACK.NATIVE_API,color);}}exports.ApiCollector=ApiCollector;
@@ -0,0 +1,40 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.CacheCollector=void 0;var _constants=require("../core/constants.js");/**
2
+ * CacheCollector.js
3
+ *
4
+ * Instruments: bugs #1, #2, #10
5
+ * #1 page slowness OTC pages → cache miss → slow fetch → resource blocks render
6
+ * #2 back-forward reloads screen → back navigation triggers a burst of cache misses
7
+ * #10 footer flash on Drug/OTC → cache miss for footer chunk → layout shift after load
8
+ *
9
+ * Native events received in two modes:
10
+ *
11
+ * 1. Live (legacy path — only used if buffer disabled):
12
+ * cache-hit-memory / cache-hit-disk / cache-miss-fetch / network-fetch-complete
13
+ * → Each emits immediately to Catalyst > Cache track via onNativeEvent()
14
+ *
15
+ * 2. Batched (primary path via __catalystPerfBatch drain in index.js):
16
+ * Same event types arrive as part of the native buffer flush after onPageFinished.
17
+ * JS drains via requestIdleCallback → onBatchEvent() → emits spans on Catalyst > Cache > Detail
18
+ *
19
+ * cache-summary { hits, misses, fetches, total, hitRatePct, avgFetchMs, topSlowest[] }
20
+ * → One summary span emitted on main Catalyst > Cache track for InsightsCollector
21
+ *
22
+ * Spans emitted:
23
+ * Catalyst > Cache > Detail individual spans (one per file) during idle drain
24
+ * Catalyst > Cache summary span (hit rate, avg fetch, top slowest)
25
+ *
26
+ * Reading the track:
27
+ * Blue = cache HIT memory — fastest path
28
+ * Blue-light = cache HIT disk — good, slightly slower
29
+ * Red = cache MISS — matched pattern but not cached
30
+ * Yellow/Orange = network fetch — orange if slow >300ms
31
+ */const TRACK_CACHE_DETAIL="Cache > Detail";class CacheCollector{constructor(measure,nativeToWeb){this._measure=measure;this._nativeToWeb=nativeToWeb;this._notifyInsights=null;// set by index.js after construction
32
+ }/** Called by index.js to wire up insights notifications for cache-summary. */setInsightsSource(fn){this._notifyInsights=fn;}init(){// No web-side observers — all events come from native
33
+ }// ── Live path (individual events routed from index.js) ──────────────────
34
+ onNativeEvent(event){const{type}=event;if(!type?.startsWith("cache-")&&type!=="network-fetch-complete")return;this._emitCacheSpan(event,_constants.TRACK.CACHE);}// ── Batch path (native buffer flush) ────────────────────────────────────
35
+ onBatchEvent(event){const{type}=event;if(type==="cache-summary"){this._emitSummarySpan(event);return;}if(!type?.startsWith("cache-")&&type!=="network-fetch-complete")return;// Individual cache spans go on the Detail sub-track
36
+ this._emitCacheSpan(event,TRACK_CACHE_DETAIL);}// ── Internal ─────────────────────────────────────────────────────────────
37
+ _emitCacheSpan(event,track){const{type,url,durationMs,nativeStartMs,resourceType,thread,statusCode}=event;const resType=resourceType??"other";const filename=this._filename(url);const endTime=nativeStartMs!=null?this._nativeToWeb(nativeStartMs)+(durationMs??0):performance.now();const startTime=endTime-(durationMs??0);let measureName,color,detail;const interceptThread=event.interceptThread??null;const emitThread=event.emitThread??null;if(type==="cache-hit-memory"){measureName=`${_constants.PREFIX.CACHE}/hit|memory|${resType}|${filename}`;color=_constants.COLOR.PRIMARY;detail={url,source:"memory",resourceType:resType,durationMs,thread:thread??null,interceptThread,emitThread,simulatorValid:false};}else if(type==="cache-hit-disk"){measureName=`${_constants.PREFIX.CACHE}/hit|disk|${resType}|${filename}`;color=_constants.COLOR.PRIMARY_LIGHT;detail={url,source:"disk",resourceType:resType,durationMs,thread:thread??null,interceptThread,emitThread,simulatorValid:false};}else if(type==="cache-miss-fetch"){measureName=`${_constants.PREFIX.CACHE}/miss|${resType}|${filename}`;color=_constants.COLOR.ERROR;detail={url,resourceType:resType,durationMs,thread:thread??null,interceptThread,emitThread,simulatorValid:false};}else if(type==="network-fetch-complete"){const slow=(durationMs??0)>_constants.THRESHOLD.FETCH_SLOW_MS;measureName=`${_constants.PREFIX.CACHE}/fetch|${resType}|${filename}`;color=slow?_constants.COLOR.TERTIARY_DARK:_constants.COLOR.TERTIARY;detail={url,resourceType:resType,durationMs,statusCode:statusCode??null,thread:thread??null,emitThread,slow,simulatorValid:false};}else{return;}const startMark=`${measureName}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._measure.emit(measureName,startMark,endTime,detail,track,color);}_emitSummarySpan(event){const{hits,misses,fetches,total,hitRatePct,avgFetchMs,topSlowest,nativeTime}=event;if(total===0)return;// Place summary span at current time (post-load, idle phase)
38
+ const startTime=this._nativeToWeb(nativeTime??0)||performance.now();const endTime=startTime+1;// point span
39
+ const color=hitRatePct>=80?_constants.COLOR.PRIMARY_DARK:hitRatePct>=50?_constants.COLOR.SECONDARY:_constants.COLOR.ERROR;const topNames=Array.isArray(topSlowest)?topSlowest.map(e=>`${e.filename}(${e.durationMs}ms)`).join(", "):"";const measureName=`${_constants.PREFIX.CACHE}/summary`;const startMark=`${measureName}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});this._measure.emit(measureName,startMark,endTime,{hits,misses,fetches,total,hitRatePct,avgFetchMs,topSlowest:topNames,simulatorValid:false},_constants.TRACK.CACHE,color);// Notify InsightsCollector so it can flag low hit rates
40
+ this._notifyInsights?.({hitRatePct,total,avgFetchMs,startTime,endTime});}_filename(url){try{const path=new URL(url).pathname;const name=path.split("/").filter(Boolean).pop()??path;return name.slice(0,40);}catch{return String(url).slice(0,40);}}}exports.CacheCollector=CacheCollector;
@@ -0,0 +1,43 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.HookCollector=void 0;var _constants=require("../core/constants.js");/**
2
+ * HookCollector.js — Option A (symptom detection only)
3
+ *
4
+ * Instruments: bugs #13, #15, #16
5
+ * #13 cart computation loop jank → LoAF bursts sustained over time, not tied to a single interaction
6
+ * detected as: LoAF count > threshold within a rolling window
7
+ * #15 quantity selector slow first → interaction → first LoAF burst → long gap → paint
8
+ * detected as: LoAF fires within LAZY_LOAF_WINDOW_MS after tap
9
+ * #16 prescription screen touch lag → same pattern as #15
10
+ *
11
+ * Option A strategy (no React hooks needed):
12
+ * - Watch for LoAF bursts within 2s of a pointerdown (taps that might trigger lazy load)
13
+ * - If LoAF count > 0 in that window → emit hook/lazy span showing the blocked period
14
+ * - Sustained LoAF (3+ frames in 5s with no user interaction) → emit hook/mount span
15
+ * flagging a background computation loop
16
+ *
17
+ * Spans emitted:
18
+ * catalyst:hook/lazy|<label> — LoAF burst after interaction (Track: Hook)
19
+ * detail: loafCount, totalBlockingMs, triggerTarget
20
+ * catalyst:hook/mount|loop — sustained background LoAF (Track: Hook)
21
+ * detail: loafCount, windowMs, likelyCause='computation-loop'
22
+ *
23
+ * TODO (Option B — future):
24
+ * Expose window.catalyst.markComponentMount(name) / markComponentReady(name)
25
+ * for precise hook-lifecycle spans without symptom inference.
26
+ */const LOOP_LOAF_COUNT=3;// ≥3 LoAFs in the loop window = computation loop
27
+ const LOOP_WINDOW_MS=5000;// rolling window to count sustained LoAFs
28
+ class HookCollector{constructor(measure){this._measure=measure;// Lazy-load detection state
29
+ this._pendingLazy=null;// { startMark, startTime, target, loafCount, totalBlocking, timer }
30
+ // Computation loop detection state
31
+ this._loopLoafs=[];// [{ start, end }] in rolling LOOP_WINDOW_MS window
32
+ }init(){// Watch for taps that might trigger lazy component loads
33
+ document.addEventListener("pointerdown",this._onPointerDown.bind(this),{passive:true});}/** Called by index.js to feed the normalized perf store. */setWaterfallSource(fn){this._notifyWaterfall=fn;}// Called by RenderCollector via addLoafListener() on every LoAF
34
+ onLoaf(start,end){// ── Lazy-load window ────────────────────────────────────────────────
35
+ if(this._pendingLazy){const p=this._pendingLazy;p.loafCount++;p.totalBlocking+=end-start;// Reset the close timer — extend window on each LoAF
36
+ clearTimeout(p.timer);p.timer=setTimeout(()=>this._closeLazySpan(),_constants.THRESHOLD.LAZY_LOAF_WINDOW_MS);}// ── Computation loop detection ───────────────────────────────────────
37
+ const now=performance.now();this._loopLoafs.push({start,end});// Evict entries outside the rolling window
38
+ this._loopLoafs=this._loopLoafs.filter(e=>now-e.start<LOOP_WINDOW_MS);if(this._loopLoafs.length>=LOOP_LOAF_COUNT){this._emitLoopSpan();this._loopLoafs=[];// reset after emitting to avoid duplicate spans
39
+ }}// ─── Lazy-load span ───────────────────────────────────────────────────────
40
+ _onPointerDown(e){// If a lazy span is already open, let it close naturally
41
+ if(this._pendingLazy)return;const target=e.target?.tagName?.toLowerCase()??"unknown";const startTime=performance.now();const startMark=`${_constants.PREFIX.HOOK_LAZY}:start:${Math.round(startTime)}`;performance.mark(startMark,{startTime});const timer=setTimeout(()=>this._closeLazySpan(),_constants.THRESHOLD.LAZY_LOAF_WINDOW_MS);this._pendingLazy={startMark,startTime,target,loafCount:0,totalBlocking:0,timer};}_closeLazySpan(){const p=this._pendingLazy;if(!p)return;this._pendingLazy=null;clearTimeout(p.timer);// Only emit if LoAF actually fired — otherwise it was a clean interaction
42
+ if(p.loafCount===0)return;const endTime=performance.now();this._measure.emit(`${_constants.PREFIX.HOOK_LAZY}|${p.target}`,p.startMark,endTime,{loafCount:p.loafCount,totalBlockingMs:Math.round(p.totalBlocking),triggerTarget:p.target,likelyCause:"lazy-load",simulatorValid:false},_constants.TRACK.HOOK);this._notifyWaterfall?.({hookType:"lazy-load",label:`Hook lazy-load ${p.target}`,startTime:p.startTime,endTime,durationMs:Math.round(endTime-p.startTime),loafCount:p.loafCount,totalBlockingMs:Math.round(p.totalBlocking),likelyCause:"lazy-load"});}// ─── Computation loop span ────────────────────────────────────────────────
43
+ _emitLoopSpan(){const first=this._loopLoafs[0];const last=this._loopLoafs[this._loopLoafs.length-1];const markName=`${_constants.PREFIX.HOOK_MOUNT}:loop:${Math.round(first.start)}`;performance.mark(markName,{startTime:first.start});const totalBlocking=this._loopLoafs.reduce((sum,e)=>sum+(e.end-e.start),0);this._measure.emit(`${_constants.PREFIX.HOOK_MOUNT}|loop`,markName,last.end,{loafCount:this._loopLoafs.length,windowMs:Math.round(last.end-first.start),totalBlockingMs:Math.round(totalBlocking),likelyCause:"computation-loop",simulatorValid:false},_constants.TRACK.HOOK);this._notifyWaterfall?.({hookType:"computation-loop",label:"Hook computation loop",startTime:first.start,endTime:last.end,durationMs:Math.round(last.end-first.start),loafCount:this._loopLoafs.length,totalBlockingMs:Math.round(totalBlocking),likelyCause:"computation-loop"});}}exports.HookCollector=HookCollector;
@@ -0,0 +1,47 @@
1
+ "use strict";Object.defineProperty(exports,"__esModule",{value:true});exports.InsightsCollector=void 0;var _constants=require("../core/constants.js");/**
2
+ * InsightsCollector.js
3
+ *
4
+ * Watches accumulated perf data and emits actionable warnings to the
5
+ * Catalyst > Insights track in Chrome DevTools.
6
+ *
7
+ * Each insight is a point-in-time span with:
8
+ * - severity: 'critical' | 'warning' | 'info'
9
+ * - rule: short machine-readable id (e.g. 'slow-page-load')
10
+ * - message: one-sentence human diagnosis
11
+ * - fix: immediate actionable recommendation
12
+ * - evidence: the numbers that triggered the rule
13
+ *
14
+ * Insights are emitted in two modes:
15
+ * 1. Reactive — emitted immediately when a threshold breach is detected
16
+ * (e.g. an fps-drop episode during interaction)
17
+ * 2. Summary — emitted once after SESSION_SUMMARY_DELAY_MS of inactivity
18
+ * to give a rolled-up view of what happened in the last session
19
+ *
20
+ * How to read this in DevTools:
21
+ * Performance tab → Catalyst > Insights track
22
+ * Click any span → Tooltip shows: severity, rule, message, fix, evidence
23
+ * Red spans = critical (user-visible jank/failure)
24
+ * Yellow (tertiary) = warning
25
+ * Blue (secondary) = info / optimization opportunity
26
+ */const SESSION_SUMMARY_DELAY_MS=3000;function round(value){return Number.isFinite(value)?Math.round(value):value;}class InsightsCollector{constructor(measure){this._measure=measure;this._summaryTimer=null;this._insightSeq=0;// Counters reset per session
27
+ this._fpsDrops=[];// [{ minFps, durationMs }]
28
+ this._slowLoads=[];// [{ url, durationMs }]
29
+ this._loafsDuringNav=0;this._slowInteractions=[];// [{ target, responseMs }]
30
+ this._highMemory=[];// [{ currentMb }]
31
+ this._layoutShifts=[];// [{ value }]
32
+ this._coldStartMs=null;// always attached to other insights once known
33
+ }init(){// No web observers — fed via notify*() methods from index.js
34
+ }setStoreSource(fn){this._notifyStore=fn;}// ─── Feed methods — called by index.js after routing native events ─────────
35
+ notifyFpsDrop({minFps,avgFps,durationMs,duringInteraction,deltaFps,baselineFps,startTime,endTime,interactionId}){const source={startTime,endTime,related:{interactionId}};this._fpsDrops.push({minFps,durationMs});if(duringInteraction){this._emitReactive({severity:"critical",rule:"fps-drop-during-tap",message:`FPS dropped to ${minFps.toFixed(1)} for ${Math.round(durationMs)}ms right after a tap`,fix:"Heavy computation is blocking the render thread after interaction — compare spans with the same interaction ID",evidence:{minFps,avgFps,durationMs,deltaFps,baselineFps},...source});}else if(minFps<30){this._emitReactive({severity:"warning",rule:"severe-fps-drop",message:`Severe FPS drop: ${minFps.toFixed(1)}fps min for ${Math.round(durationMs)}ms`,fix:"Look for LoAF spans overlapping this window on the Render track — a long script or layout is stalling frames",evidence:{minFps,avgFps,durationMs,deltaFps,baselineFps},...source});}else if(deltaFps!=null&&deltaFps>=10){// Sudden spike: FPS fell sharply even if absolute value is not critical
36
+ this._emitReactive({severity:"warning",rule:"fps-spike-drop",message:`Sudden FPS spike: dropped ${deltaFps.toFixed(1)}fps (${baselineFps?.toFixed(1)} → ${minFps.toFixed(1)}) in one frame window`,fix:"A sudden spike drop — often a one-off heavy layout or image decode. Check Render track for a LoAF aligned with this episode.",evidence:{minFps,avgFps,durationMs,deltaFps,baselineFps},...source});}this._scheduleSummary();}notifySlowLoad({url,durationMs,startTime,endTime}){this._slowLoads.push({url,durationMs});if(durationMs>4000){this._emitReactive({severity:"critical",rule:"page-load-too-slow",message:`Page load took ${Math.round(durationMs)}ms (${url.split("/").pop()||url})`,fix:"Check Cache track for cache-miss-fetch bursts; consider preloading route chunks or warming the WebView cache",evidence:{url,durationMs},startTime,endTime});}this._scheduleSummary();}notifyColdStart({durationMs,startTime,endTime}){this._coldStartMs=durationMs;// attach to all future insights automatically
37
+ const severity=durationMs>3000?"critical":durationMs>1800?"warning":"info";const label=durationMs>3000?"slow":durationMs>1800?"acceptable":"fast";this._emitReactive({severity,rule:"cold-start-time",message:`Cold start: ${Math.round(durationMs)}ms (${label}) — app launched to first page finish`,fix:durationMs>3000?"Cold start >3s — check if WebView.loadUrl() is called before layout inflation completes; defer non-critical MainActivity init; consider prewarming WebView in Application.onCreate()":durationMs>1800?"Cold start >1.8s — consider moving WebView construction earlier (Application.onCreate vs MainActivity.onCreate); check if large local assets block onPageFinished":"Cold start within target (≤1.8s). No action needed.",evidence:{durationMs,targetMs:1800,acceptableMs:3000},startTime,endTime});}notifyLoafDuringNav({durationMs,startTime,endTime}){this._loafsDuringNav++;if(this._loafsDuringNav===1){this._emitReactive({severity:"warning",rule:"loaf-blocking-nav",message:`Long animation frame (${Math.round(durationMs)}ms) fired during navigation — may cause blank screen`,fix:"Defer non-critical JS execution after page-load-end; split large bundles with dynamic import()",evidence:{durationMs,loafsDuringNav:this._loafsDuringNav},startTime,endTime});}}notifySlowInteraction({target,responseMs,startTime,endTime,interactionId}){this._slowInteractions.push({target,responseMs});if(responseMs>300){this._emitReactive({severity:"critical",rule:"interaction-unresponsive",message:`Tap on '${target}' took ${responseMs}ms to paint (threshold ${_constants.THRESHOLD.INTERACTION_SLOW_MS}ms)`,fix:"A LoAF is delaying the paint — inspect work overlapping the interaction",evidence:{target,responseMs,thresholdMs:_constants.THRESHOLD.INTERACTION_SLOW_MS},startTime,endTime,related:{interactionId}});}this._scheduleSummary();}notifyHighMemory({currentMb,startTime,endTime,memoryId}){const source={startTime,endTime,related:{memoryId}};// currentMb is webviewMb (V8 + Blink + JNI PSS) — the WebView engine memory.
38
+ // A healthy WebView app idles at 80-120MB. Growth beyond that signals
39
+ // a real leak (un-released image bitmaps, accumulating JS closures, JNI objects).
40
+ // Thresholds are intentionally higher than total-PSS thresholds used previously
41
+ // because we now track the native slice, not the whole process.
42
+ this._highMemory.push({currentMb});if(currentMb>120){this._emitReactive({severity:"critical",rule:"memory-critical",message:`WebView memory at ${Math.round(currentMb)}MB — above 120MB critical threshold (V8/Blink/JNI leak likely)`,fix:"Check for un-released image bitmaps, accumulating JS closures, or JNI objects not returned to GC. Compare WebView memory growth across snapshots on the Memory track.",evidence:{webviewMb:currentMb},...source});}else if(currentMb>80){this._emitReactive({severity:"warning",rule:"memory-high",message:`WebView memory at ${Math.round(currentMb)}MB — above 80MB warning threshold (healthy idle is <80MB)`,fix:"Monitor WebView memory on the Memory track — sustained growth = leak. Check image cache eviction and dispose WebView listeners on route unmount.",evidence:{webviewMb:currentMb},...source});}}notifyHwAccelDuringFrame({durationMs,thread,trigger,startTime,endTime}){this._emitReactive({severity:"critical",rule:"hw-accel-during-frame",message:`Software rendering was active for ${Math.round(durationMs)}ms during a dropped frame (hw-accel disabled by ${trigger??"cache-serve"})`,fix:"Cache serving on shouldInterceptRequest disables hardware acceleration — move cache I/O off the WebView thread or batch cache warming on app start to reduce mid-session hw-accel toggles",evidence:{durationMs,thread:thread??"unknown",trigger:trigger??"unknown"},startTime,endTime});}notifyLayoutShift({value,sessionContext,startTime,endTime}){this._layoutShifts.push({value});if(value>0.25){this._emitReactive({severity:"warning",rule:"large-layout-shift",message:`Large layout shift (CLS ${value.toFixed(3)}) during '${sessionContext}' phase`,fix:"Reserve space for async-loaded content (images, ads, banners) using explicit height; avoid injecting DOM above visible content",evidence:{value,sessionContext},startTime,endTime});}}notifyScrollJank({minFps,frameDropCount,startTime,endTime}){const severity=minFps<30?"critical":"warning";const rule=minFps<30?"scroll-jank-critical":"scroll-jank-warning";this._emitReactive({severity,rule,message:`Scroll jank: ${Math.round(frameDropCount)} frame drop(s), min ${minFps.toFixed(1)}fps during scroll`,fix:minFps<30?"Severe scroll jank — check for layout thrash triggered during scroll. Look for LoAF spans on Render track overlapping the Input track scroll session.":"Scroll dropped below 55fps — common causes: sticky header recalc, cart badge updates, or image decode on scroll. Check Render track LoAF spans inside the scroll session.",evidence:{minFps,frameDropCount},startTime,endTime});this._scheduleSummary();}notifyCacheSummary({hitRatePct,total,avgFetchMs,startTime,endTime}){if(total<5)return;// not enough data
43
+ if(hitRatePct<50){this._emitReactive({severity:"warning",rule:"low-cache-hit-rate",message:`Cache hit rate ${hitRatePct}% (${total} resources) — majority served via network`,fix:"Add missed URLs to the allowedUrls cache list; check cache max-age and disk eviction policy",evidence:{hitRatePct,total,avgFetchMs},startTime,endTime});}}// ─── Reactive insight emission ─────────────────────────────────────────────
44
+ _emitReactive({severity,rule,message,fix,evidence={},startTime,endTime,related={}}){const start=Number.isFinite(startTime)?startTime:performance.now();const end=Number.isFinite(endTime)?Math.max(start+1,endTime):start+2;const markName=`${_constants.PREFIX.INSIGHT}:${rule}:${Math.round(start)}:${++this._insightSeq}`;const color=severity==="critical"?"error":severity==="warning"?"tertiary":"secondary";const title=this._titleFor(rule,evidence,message);const category=this._categoryFor(rule);const detail={severity,rule,title,category,message,fix,...related,...(this._coldStartMs!=null?{"coldStart.ms":String(Math.round(this._coldStartMs))}:{}),...Object.fromEntries(Object.entries(evidence).filter(([,value])=>value!=null).map(([key,value])=>[`evidence.${key}`,String(value)]))};performance.mark(markName,{startTime:start});this._notifyStore?.({severity,rule,title,category,message,fix,evidence,...related,startTime:start,endTime:end,durationMs:end-start});this._measure.emit(`${related.interactionId?`[${related.interactionId}] `:""}Insight: ${title}`,markName,end,detail,_constants.TRACK.INSIGHTS,color);}_titleFor(rule,evidence,message){switch(rule){case"fps-drop-during-tap":return`FPS drop after tap: ${round(evidence.minFps)}fps`;case"severe-fps-drop":return`FPS drop: ${round(evidence.minFps)}fps for ${round(evidence.durationMs)}ms`;case"fps-spike-drop":return`FPS spike drop: -${round(evidence.deltaFps)}fps`;case"page-load-too-slow":return`Page load slow: ${round(evidence.durationMs)}ms`;case"cold-start-time":return`Cold start: ${round(evidence.durationMs)}ms`;case"loaf-blocking-nav":return`Render blocked during navigation: ${round(evidence.durationMs)}ms`;case"interaction-unresponsive":return`Slow tap: ${evidence.target??"unknown"} ${round(evidence.responseMs)}ms`;case"memory-critical":case"memory-high":return`Memory high: ${round(evidence.webviewMb)}MB`;case"hw-accel-during-frame":return`Software render window: ${round(evidence.durationMs)}ms`;case"large-layout-shift":return`Large layout shift: ${Number.isFinite(evidence.value)?evidence.value.toFixed(3):"unknown"}`;case"scroll-jank-critical":case"scroll-jank-warning":return`Scroll jank: ${round(evidence.minFps)}fps`;case"low-cache-hit-rate":return`Cache hit rate low: ${evidence.hitRatePct}%`;case"session-summary":return"Session issues";default:return message??rule;}}_categoryFor(rule){if(rule.includes("fps")||rule.includes("loaf")||rule.includes("layout"))return"render";if(rule.includes("page-load")||rule.includes("cold-start"))return"navigation";if(rule.includes("interaction")||rule.includes("tap"))return"interaction";if(rule.includes("memory"))return"memory";if(rule.includes("scroll"))return"scroll";if(rule.includes("cache"))return"cache";if(rule.includes("session"))return"session";return"general";}// ─── Session summary ───────────────────────────────────────────────────────
45
+ // Emitted after SESSION_SUMMARY_DELAY_MS of quiet — a rolled-up health check.
46
+ _scheduleSummary(){if(this._summaryTimer)clearTimeout(this._summaryTimer);this._summaryTimer=setTimeout(()=>this._emitSummary(),SESSION_SUMMARY_DELAY_MS);}_emitSummary(){this._summaryTimer=null;const problems=[];if(this._fpsDrops.length>0)problems.push(`${this._fpsDrops.length} fps-drop episode(s), min ${Math.min(...this._fpsDrops.map(f=>f.minFps)).toFixed(0)}fps`);if(this._slowLoads.length>0)problems.push(`${this._slowLoads.length} slow page load(s)`);if(this._slowInteractions.length>0)problems.push(`${this._slowInteractions.length} slow tap(s)`);if(this._highMemory.length>0)problems.push(`peak ${Math.max(...this._highMemory.map(m=>m.currentMb)).toFixed(0)}MB memory`);const severity=this._fpsDrops.some(f=>f.minFps<20)?"critical":problems.length>2?"warning":"info";const message=problems.length===0?"Session clean — no threshold breaches detected":`Session issues: ${problems.join(" · ")}`;const fix=problems.length===0?"No action needed":"Expand each red/yellow span above for per-issue fixes";this._emitReactive({severity:severity==="info"?"info":severity,rule:"session-summary",message,fix,evidence:{fpsDropEpisodes:this._fpsDrops.length,slowPageLoads:this._slowLoads.length,slowInteractions:this._slowInteractions.length,highMemoryReadings:this._highMemory.length,layoutShifts:this._layoutShifts.length}});// Reset counters for next session
47
+ this._fpsDrops=[];this._slowLoads=[];this._loafsDuringNav=0;this._slowInteractions=[];this._highMemory=[];this._layoutShifts=[];}}exports.InsightsCollector=InsightsCollector;
@@ -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"};