remix-use-spa-metrics
Advanced tools
Comparing version 0.1.0 to 0.2.0
@@ -19,4 +19,20 @@ export declare enum RemixPerfMark { | ||
state: "idle" | "submitting" | "loading"; | ||
location: { | ||
pathname: string; | ||
search: string; | ||
}; | ||
} | ||
export declare function useSpaMetrics(navigation: Navigation): void; | ||
export interface CallbackData { | ||
type: "submitting" | "loading"; | ||
fromLocation: Location; | ||
toLocation: Location; | ||
finalLocation: Location; | ||
submissionDuration?: number; | ||
loadingDuration: number; | ||
totalDuration: number; | ||
} | ||
export interface CallbackFunction { | ||
(data: CallbackData): void; | ||
} | ||
export declare function useSpaMetrics(location: Location, navigation: Navigation, callback: CallbackFunction): void; | ||
export {}; |
@@ -0,1 +1,20 @@ | ||
var __defProp = Object.defineProperty; | ||
var __defProps = Object.defineProperties; | ||
var __getOwnPropDescs = Object.getOwnPropertyDescriptors; | ||
var __getOwnPropSymbols = Object.getOwnPropertySymbols; | ||
var __hasOwnProp = Object.prototype.hasOwnProperty; | ||
var __propIsEnum = Object.prototype.propertyIsEnumerable; | ||
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; | ||
var __spreadValues = (a, b) => { | ||
for (var prop in b || (b = {})) | ||
if (__hasOwnProp.call(b, prop)) | ||
__defNormalProp(a, prop, b[prop]); | ||
if (__getOwnPropSymbols) | ||
for (var prop of __getOwnPropSymbols(b)) { | ||
if (__propIsEnum.call(b, prop)) | ||
__defNormalProp(a, prop, b[prop]); | ||
} | ||
return a; | ||
}; | ||
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); | ||
import * as React from "react"; | ||
@@ -28,5 +47,8 @@ var RemixPerfMark = /* @__PURE__ */ ((RemixPerfMark2) => { | ||
} | ||
function getLastMark() { | ||
let marks = window.performance.getEntriesByType("mark"); | ||
return findRight(marks, (m) => m.detail.id === navId); | ||
function getLastNavEntry(type, predicate) { | ||
let marks = window.performance.getEntriesByType(type); | ||
return findRight(marks, (m) => { | ||
var _a; | ||
return ((_a = m.detail) == null ? void 0 : _a.id) === navId && (!predicate || predicate(m)); | ||
}); | ||
} | ||
@@ -37,52 +59,76 @@ function wasSubmissionNavigation() { | ||
} | ||
function useSpaMetrics(navigation) { | ||
function mark(name, detail) { | ||
return window.performance.mark(name, { | ||
detail: __spreadProps(__spreadValues({}, detail), { | ||
id: navId | ||
}) | ||
}); | ||
} | ||
function measure(name, start, end) { | ||
return window.performance.measure(name, { | ||
start, | ||
end, | ||
detail: { | ||
id: navId | ||
} | ||
}); | ||
} | ||
function completeNavigation(type, callback) { | ||
let startMark = getLastNavEntry("mark", (m) => m.detail.fromLocation != null); | ||
let submissionMeasure = type === "submitting" ? getLastNavEntry("measure", (m) => m.name === "\u{1F4BF}:submitting") : null; | ||
let loadingMeasure = getLastNavEntry("measure", (m) => m.name === "\u{1F4BF}:loading"); | ||
let navMeasure = getLastNavEntry("measure", (m) => m.name === (type === "submitting" ? "\u{1F4BF}:submission-navigation" : "\u{1F4BF}:loading-navigation")); | ||
if (!startMark || !navMeasure || !loadingMeasure) { | ||
console.error("Could not find proper nav marks/measures, skipping SPA metrics"); | ||
return; | ||
} | ||
callback({ | ||
type, | ||
fromLocation: startMark.detail.fromLocation, | ||
toLocation: startMark.detail.toLocation, | ||
finalLocation: location, | ||
submissionDuration: submissionMeasure == null ? void 0 : submissionMeasure.duration, | ||
loadingDuration: loadingMeasure.duration, | ||
totalDuration: navMeasure.duration | ||
}); | ||
} | ||
function useSpaMetrics(location2, navigation, callback) { | ||
React.useEffect(() => { | ||
if (navigation.state === "idle") { | ||
if (!getLastMark()) { | ||
if (!getLastNavEntry("mark")) { | ||
return; | ||
} | ||
if (wasSubmissionNavigation()) { | ||
window.performance.mark("\u{1F4BF}:loading-end", { | ||
detail: { id: navId } | ||
}); | ||
window.performance.mark("\u{1F4BF}:submission-navigation-end", { | ||
detail: { id: navId } | ||
}); | ||
window.performance.measure("\u{1F4BF}:submission-navigation", "\u{1F4BF}:submission-navigation-start", "\u{1F4BF}:submission-navigation-end"); | ||
window.performance.measure("\u{1F4BF}:loading", "\u{1F4BF}:loading-start", "\u{1F4BF}:loading-end"); | ||
mark("\u{1F4BF}:loading-end"); | ||
mark("\u{1F4BF}:submission-navigation-end"); | ||
measure("\u{1F4BF}:submission-navigation", "\u{1F4BF}:submission-navigation-start", "\u{1F4BF}:submission-navigation-end"); | ||
measure("\u{1F4BF}:loading", "\u{1F4BF}:loading-start", "\u{1F4BF}:loading-end"); | ||
completeNavigation("submitting", callback); | ||
} else { | ||
window.performance.mark("\u{1F4BF}:loading-end", { | ||
detail: { id: navId } | ||
}); | ||
window.performance.mark("\u{1F4BF}:loading-navigation-end", { | ||
detail: { id: navId } | ||
}); | ||
window.performance.measure("\u{1F4BF}:loading-navigation", "\u{1F4BF}:loading-navigation-start", "\u{1F4BF}:loading-navigation-end"); | ||
window.performance.measure("\u{1F4BF}:loading", "\u{1F4BF}:loading-start", "\u{1F4BF}:loading-end"); | ||
mark("\u{1F4BF}:loading-end"); | ||
mark("\u{1F4BF}:loading-navigation-end"); | ||
measure("\u{1F4BF}:loading-navigation", "\u{1F4BF}:loading-navigation-start", "\u{1F4BF}:loading-navigation-end"); | ||
measure("\u{1F4BF}:loading", "\u{1F4BF}:loading-start", "\u{1F4BF}:loading-end"); | ||
completeNavigation("loading", callback); | ||
} | ||
navId++; | ||
} else if (navigation.state === "submitting") { | ||
navId++; | ||
window.performance.mark("\u{1F4BF}:submission-navigation-start", { | ||
detail: { id: navId } | ||
mark("\u{1F4BF}:submission-navigation-start", { | ||
fromLocation: location2, | ||
toLocation: navigation.location | ||
}); | ||
window.performance.mark("\u{1F4BF}:submitting-start", { | ||
mark("\u{1F4BF}:submitting-start", { | ||
detail: { id: navId } | ||
}); | ||
} else if (navigation.state === "loading") { | ||
if (!getLastMark()) { | ||
navId++; | ||
window.performance.mark("\u{1F4BF}:loading-navigation-start", { | ||
detail: { id: navId } | ||
if (!getLastNavEntry("mark")) { | ||
mark("\u{1F4BF}:loading-navigation-start", { | ||
fromLocation: location2, | ||
toLocation: navigation.location | ||
}); | ||
window.performance.mark("\u{1F4BF}:loading-start", { | ||
detail: { id: navId } | ||
}); | ||
mark("\u{1F4BF}:loading-start"); | ||
} else { | ||
window.performance.mark("\u{1F4BF}:submitting-end", { | ||
detail: { id: navId } | ||
}); | ||
window.performance.measure("\u{1F4BF}:submitting", "\u{1F4BF}:submitting-start", "\u{1F4BF}:submitting-end"); | ||
window.performance.mark("\u{1F4BF}:loading-start", { | ||
detail: { id: navId } | ||
}); | ||
mark("\u{1F4BF}:submitting-end"); | ||
measure("\u{1F4BF}:submitting", "\u{1F4BF}:submitting-start", "\u{1F4BF}:submitting-end"); | ||
mark("\u{1F4BF}:loading-start"); | ||
} | ||
@@ -89,0 +135,0 @@ } |
@@ -1,1 +0,1 @@ | ||
(function(a,o){typeof exports=="object"&&typeof module!="undefined"?o(exports,require("react")):typeof define=="function"&&define.amd?define(["exports","react"],o):(a=typeof globalThis!="undefined"?globalThis:a||self,o(a.useSpaMetrics={},a.React))})(this,function(a,o){"use strict";function g(i){if(i&&i.__esModule)return i;var t=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});return i&&Object.keys(i).forEach(function(e){if(e!=="default"){var F=Object.getOwnPropertyDescriptor(i,e);Object.defineProperty(t,e,F.get?F:{enumerable:!0,get:function(){return i[e]}})}}),t.default=i,Object.freeze(t)}var m=g(o),u=(i=>(i.SubmissionNavigationStart="\u{1F4BF}:submission-navigation-start",i.SubmissionNavigationEnd="\u{1F4BF}:submission-navigation-end",i.LoadingNavigationStart="\u{1F4BF}:loading-navigation-start",i.LoadingNavigationEnd="\u{1F4BF}:loading-navigation-end",i.SubmittingStart="\u{1F4BF}:submitting-start",i.SubmittingEnd="\u{1F4BF}:submitting-end",i.LoadingStart="\u{1F4BF}:loading-start",i.LoadingEnd="\u{1F4BF}:loading-end",i))(u||{}),d=(i=>(i.SubmissionNavigation="\u{1F4BF}:submission-navigation",i.Submitting="\u{1F4BF}:submitting",i.Loading="\u{1F4BF}:loading",i.LoadingNavigation="\u{1F4BF}:loading-navigation",i))(d||{});let n=0;function r(i,t){for(let e=i.length-1;e>=0;e--)if(t(i[e]))return i[e]}function s(){let i=window.performance.getEntriesByType("mark");return r(i,t=>t.detail.id===n)}function l(){let i=window.performance.getEntriesByType("mark");return r(i,t=>t.detail.id===n&&t.name==="\u{1F4BF}:submission-navigation-start")}function f(i){m.useEffect(()=>{if(i.state==="idle"){if(!s())return;l()?(window.performance.mark("\u{1F4BF}:loading-end",{detail:{id:n}}),window.performance.mark("\u{1F4BF}:submission-navigation-end",{detail:{id:n}}),window.performance.measure("\u{1F4BF}:submission-navigation","\u{1F4BF}:submission-navigation-start","\u{1F4BF}:submission-navigation-end"),window.performance.measure("\u{1F4BF}:loading","\u{1F4BF}:loading-start","\u{1F4BF}:loading-end")):(window.performance.mark("\u{1F4BF}:loading-end",{detail:{id:n}}),window.performance.mark("\u{1F4BF}:loading-navigation-end",{detail:{id:n}}),window.performance.measure("\u{1F4BF}:loading-navigation","\u{1F4BF}:loading-navigation-start","\u{1F4BF}:loading-navigation-end"),window.performance.measure("\u{1F4BF}:loading","\u{1F4BF}:loading-start","\u{1F4BF}:loading-end"))}else i.state==="submitting"?(n++,window.performance.mark("\u{1F4BF}:submission-navigation-start",{detail:{id:n}}),window.performance.mark("\u{1F4BF}:submitting-start",{detail:{id:n}})):i.state==="loading"&&(s()?(window.performance.mark("\u{1F4BF}:submitting-end",{detail:{id:n}}),window.performance.measure("\u{1F4BF}:submitting","\u{1F4BF}:submitting-start","\u{1F4BF}:submitting-end"),window.performance.mark("\u{1F4BF}:loading-start",{detail:{id:n}})):(n++,window.performance.mark("\u{1F4BF}:loading-navigation-start",{detail:{id:n}}),window.performance.mark("\u{1F4BF}:loading-start",{detail:{id:n}})))},[i.state])}a.RemixPerfMark=u,a.RemixPerfMeasure=d,a.useSpaMetrics=f,Object.defineProperties(a,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})}); | ||
(function(a,o){typeof exports=="object"&&typeof module!="undefined"?o(exports,require("react")):typeof define=="function"&&define.amd?define(["exports","react"],o):(a=typeof globalThis!="undefined"?globalThis:a||self,o(a.useSpaMetrics={},a.React))})(this,function(a,o){"use strict";var E=Object.defineProperty,j=Object.defineProperties;var O=Object.getOwnPropertyDescriptors;var v=Object.getOwnPropertySymbols;var _=Object.prototype.hasOwnProperty,h=Object.prototype.propertyIsEnumerable;var p=(a,o,e)=>o in a?E(a,o,{enumerable:!0,configurable:!0,writable:!0,value:e}):a[o]=e,S=(a,o)=>{for(var e in o||(o={}))_.call(o,e)&&p(a,e,o[e]);if(v)for(var e of v(o))h.call(o,e)&&p(a,e,o[e]);return a},L=(a,o)=>j(a,O(o));function e(i){if(i&&i.__esModule)return i;var n=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});return i&&Object.keys(i).forEach(function(t){if(t!=="default"){var s=Object.getOwnPropertyDescriptor(i,t);Object.defineProperty(n,t,s.get?s:{enumerable:!0,get:function(){return i[t]}})}}),n.default=i,Object.freeze(n)}var w=e(o),m=(i=>(i.SubmissionNavigationStart="\u{1F4BF}:submission-navigation-start",i.SubmissionNavigationEnd="\u{1F4BF}:submission-navigation-end",i.LoadingNavigationStart="\u{1F4BF}:loading-navigation-start",i.LoadingNavigationEnd="\u{1F4BF}:loading-navigation-end",i.SubmittingStart="\u{1F4BF}:submitting-start",i.SubmittingEnd="\u{1F4BF}:submitting-end",i.LoadingStart="\u{1F4BF}:loading-start",i.LoadingEnd="\u{1F4BF}:loading-end",i))(m||{}),c=(i=>(i.SubmissionNavigation="\u{1F4BF}:submission-navigation",i.Submitting="\u{1F4BF}:submitting",i.Loading="\u{1F4BF}:loading",i.LoadingNavigation="\u{1F4BF}:loading-navigation",i))(c||{});let d=0;function f(i,n){for(let t=i.length-1;t>=0;t--)if(n(i[t]))return i[t]}function F(i,n){let t=window.performance.getEntriesByType(i);return f(t,s=>{var l;return((l=s.detail)==null?void 0:l.id)===d&&(!n||n(s))})}function y(){let i=window.performance.getEntriesByType("mark");return f(i,n=>n.detail.id===d&&n.name==="\u{1F4BF}:submission-navigation-start")}function u(i,n){return window.performance.mark(i,{detail:L(S({},n),{id:d})})}function g(i,n,t){return window.performance.measure(i,{start:n,end:t,detail:{id:d}})}function B(i,n){let t=F("mark",r=>r.detail.fromLocation!=null),s=i==="submitting"?F("measure",r=>r.name==="\u{1F4BF}:submitting"):null,l=F("measure",r=>r.name==="\u{1F4BF}:loading"),b=F("measure",r=>r.name===(i==="submitting"?"\u{1F4BF}:submission-navigation":"\u{1F4BF}:loading-navigation"));if(!t||!b||!l){console.error("Could not find proper nav marks/measures, skipping SPA metrics");return}n({type:i,fromLocation:t.detail.fromLocation,toLocation:t.detail.toLocation,finalLocation:location,submissionDuration:s==null?void 0:s.duration,loadingDuration:l.duration,totalDuration:b.duration})}function N(i,n,t){w.useEffect(()=>{if(n.state==="idle"){if(!F("mark"))return;y()?(u("\u{1F4BF}:loading-end"),u("\u{1F4BF}:submission-navigation-end"),g("\u{1F4BF}:submission-navigation","\u{1F4BF}:submission-navigation-start","\u{1F4BF}:submission-navigation-end"),g("\u{1F4BF}:loading","\u{1F4BF}:loading-start","\u{1F4BF}:loading-end"),B("submitting",t)):(u("\u{1F4BF}:loading-end"),u("\u{1F4BF}:loading-navigation-end"),g("\u{1F4BF}:loading-navigation","\u{1F4BF}:loading-navigation-start","\u{1F4BF}:loading-navigation-end"),g("\u{1F4BF}:loading","\u{1F4BF}:loading-start","\u{1F4BF}:loading-end"),B("loading",t)),d++}else n.state==="submitting"?(u("\u{1F4BF}:submission-navigation-start",{fromLocation:i,toLocation:n.location}),u("\u{1F4BF}:submitting-start",{detail:{id:d}})):n.state==="loading"&&(F("mark")?(u("\u{1F4BF}:submitting-end"),g("\u{1F4BF}:submitting","\u{1F4BF}:submitting-start","\u{1F4BF}:submitting-end"),u("\u{1F4BF}:loading-start")):(u("\u{1F4BF}:loading-navigation-start",{fromLocation:i,toLocation:n.location}),u("\u{1F4BF}:loading-start")))},[n.state])}a.RemixPerfMark=m,a.RemixPerfMeasure=c,a.useSpaMetrics=N,Object.defineProperties(a,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})}); |
{ | ||
"name": "remix-use-spa-metrics", | ||
"version": "0.1.0", | ||
"version": "0.2.0", | ||
"description": "Custom hook for tracking client-side navigation performance in Remix/React-Router applications", | ||
@@ -39,9 +39,14 @@ "author": "matt@brophy.org", | ||
"build": "vite build && tsc -b", | ||
"dev": "vite dev" | ||
"dev": "vite dev", | ||
"prepublishOnly": "npm run build" | ||
}, | ||
"peerDependencies": { | ||
"react": ">=16.8" | ||
"react": ">=16.8", | ||
"react-dom": ">=16.8" | ||
}, | ||
"devDependencies": { | ||
"@types/node": "17.0.40", | ||
"@types/react": "18.0.10", | ||
"@vitejs/plugin-react": "1.3.2", | ||
"react-router-dom": "6.4.0-pre.2", | ||
"typescript": "4.7.2", | ||
@@ -48,0 +53,0 @@ "vite": "2.9.9" |
# remix-use-spa-metrics | ||
Custom hook for tracking client-side navigation performance in Remix/React-Router applications | ||
## Motivation | ||
The [current performance landscape](https://web.dev/vitals/#core-web-vitals) focuses primarily on SSR metrics, but largely ignores any standardized set of metrics for subsequent SPA navigations. This is understandable since the approach used for SPA navigations varies so much from app to tpp that it would likely be incredibly hard to come up with a on-size-fits-all set of metrics. | ||
However, if you're building a universal app that SSR's and then hydrates into a SPA - that SSR page load may only account for a small percentage of your users page load sin their session. shouldn't we be looking at the stats for pages 2 through N? | ||
This custom hook aims to facilitate your own standardized set of measurements for your Remix/React-Router (>=6.4.0) by leveraging the [`useNavigation`](https://beta.reactrouter.com/en/v6.4.0-pre.2/hooks/use-navigation) (`useTransition` in Remix) hook which tells you when your application is performing a SPA navigation between pages. | ||
You can then send the data off to whatever performance tracking setup you use. In a former life, I did this through [Blue Tringle Custom Timers](https://help.bluetriangle.com/hc/en-us/articles/360039526094-Custom-Timer-Implementation). | ||
## Installation | ||
```bash | ||
npm install remix-use-spa-metrics | ||
# or | ||
yarn add remix-use-spa-metrics | ||
``` | ||
## Usage | ||
The best way to leverage this is to put a single root layout wrapper around your entire application, and then wire up the hook a single time in the root: | ||
```jsx | ||
import { useSpaMetrics } from "remix-use-spa-metrics"; | ||
function RootLayout() { | ||
let location = useLocation(); | ||
let navigation = useNavigation(); | ||
let callback = React.useCallback((data) => { | ||
// This function is called once at the end of each navigation | ||
}, []); | ||
useSpaMetrics(location, navigation, callback); | ||
... | ||
} | ||
``` | ||
Then, you can use the callback function to send the data off to your | ||
performance tracking service. The `data` parameter is of the following shape: | ||
```typescript | ||
interface CallbackData { | ||
type: "submitting" | "loading"; | ||
fromLocation: Location; | ||
toLocation: Location; | ||
finalLocation: Location; | ||
submissionDuration?: number; | ||
loadingDuration: number; | ||
totalDuration: number; | ||
} | ||
``` | ||
On standard `<Link>` loading navigations, you'll get an approximately equal `loadingDuration` and `totalDuration`. Non-GET `<Form>` submissions will provide a `submissionDuration` and a `loadingDuration` that will sum up to `totalDuration` | ||
`fromLocation` and `toLocation` provide the `useLocation()` and `useNavigation().location` values from the start of the navigation. `finalLocation` reflects the final `useLocation()` whn we returned to an `idle` state since we may have redirected a few times while in the `loading` state. | ||
Eventually, this hook may provide more granular detail about encountered redirects and subsequent load times but for now we view "navigations" from the user's eyes - as a single duration from the moment they click a Link or submit a Form until they are viewing the resulting page. | ||
### `window.performance.mark` and `window.performance.measure` | ||
Under the hood, this is all implemented using the [`window.performance.mark`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/mark) and [`window.performance.measure`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure) APIs, so you automatically get the built-in dev tools functionality there, such as showing the measurements in the Profiler and access to your individual marks via `window.performance.getEntries*`: | ||
 |
17231
189
68
2
6