Skip to content

Commit

Permalink
feat!: BREAKING Make WebVitals a Plugin (#45)
Browse files Browse the repository at this point in the history
This removes all the optional/nullable stuff we have to add to parts of
Portals code. If a user wants or needs WebVitals output for their
portals, they can add it as an instance plugin on the portal.

This also makes initialContext on Android a JavascriptInterface
call instead of setting window.portalInitialContext on the window. I
personally noted some scenarios where initialContext was not always
there. I believe this due to the nature of how we had to
evaluateJavascript on the webview every time a url was loaded. The
JavascriptInterface method makes this always available.
---------

Co-authored-by: Carl Poole <carl@ionic.io>
  • Loading branch information
Steven0351 and carlpoole authored May 17, 2023
1 parent 40161b2 commit 151b1c4
Show file tree
Hide file tree
Showing 8 changed files with 1,406 additions and 80 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,7 @@ lint/tmp/

# Android Profiling
*.hprof

# Web
node_modules/
vitals/dist
62 changes: 12 additions & 50 deletions IonicPortals/src/main/kotlin/io/ionic/portals/PortalFragment.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package io.ionic.portals

import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.res.Configuration
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.JavascriptInterface
import androidx.annotation.NonNull
import androidx.fragment.app.Fragment
import com.getcapacitor.*
Expand Down Expand Up @@ -38,7 +37,6 @@ open class PortalFragment : Fragment {
var portal: Portal? = null
var liveUpdateFiles: File? = null
var onBridgeAvailable: ((bridge: Bridge) -> Unit)? = null
var webVitalsCallback: ((WebVitals.Metric, Long) -> Unit)? = null

private var bridge: Bridge? = null
private var keepRunning = true
Expand All @@ -56,14 +54,9 @@ open class PortalFragment : Fragment {
this.portal = portal
}

constructor(portal: Portal?, onBridgeAvailable: (bridge: Bridge) -> Unit) : this(portal, onBridgeAvailable, null)

constructor(portal: Portal?, webVitalsCallback: ((WebVitals.Metric, Long) -> Unit)) : this(portal, null, webVitalsCallback)

constructor(portal: Portal?, onBridgeAvailable: ((bridge: Bridge) -> Unit)?, webVitalsCallback: ((WebVitals.Metric, Long) -> Unit)?) {
constructor(portal: Portal?, onBridgeAvailable: ((bridge: Bridge) -> Unit)?) {
this.portal = portal
this.onBridgeAvailable = onBridgeAvailable
this.webVitalsCallback = webVitalsCallback
}

/**
Expand Down Expand Up @@ -357,6 +350,7 @@ open class PortalFragment : Fragment {
/**
* Sets up the supporting JavaScript code that Portals needs on the web view content.
*/
@SuppressLint("JavascriptInterface")
private fun setupPortalsJS() {
val initialContext = this.initialContext ?: portal?.initialContext

Expand All @@ -383,6 +377,14 @@ open class PortalFragment : Fragment {
portalInitialContext.put("value", initialContextValues)
}

bridge?.webView?.addJavascriptInterface(
object {
@JavascriptInterface
fun initialContext() = portalInitialContext.toString()
},
"AndroidInitialContext"
)

portal?.assetMaps?.let { assetmaps ->
if (assetmaps.isNotEmpty()) {
val assetMapsJSON = JSONObject()
Expand All @@ -392,46 +394,6 @@ open class PortalFragment : Fragment {
portalInitialContext.put("assets", assetMapsJSON)
}
}

// Add interface for WebVitals interaction
webVitalsCallback?.let { webvitalsCallback ->
bridge?.webView?.addJavascriptInterface(WebVitals(portal!!.name, webvitalsCallback), "WebVitals")
}

val newWebViewClient = object: BridgeWebViewClient(bridge) {
var hasMainRun = false
var hasBeenSetup = false

override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?
): WebResourceResponse? {
view?.post {
run {
if (!hasBeenSetup && hasMainRun) {
// Add WebVitals javascript to the webview
webVitalsCallback?.let { webvitalsCallback ->
view.evaluateJavascript(WebVitals(portal!!.name, webvitalsCallback).js, null)
}


hasBeenSetup = true
}

hasMainRun = true

// Add initial context to the webview
view.evaluateJavascript(
"window.portalInitialContext = $portalInitialContext", null
)
}
}

return super.shouldInterceptRequest(view, request)
}
}

bridge?.webView?.webViewClient = newWebViewClient
}

/**
Expand Down
21 changes: 3 additions & 18 deletions IonicPortals/src/main/kotlin/io/ionic/portals/PortalView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ class PortalView : FrameLayout {
private var mTransitioningFragmentViews: ArrayList<View>? = null
private var mDrawDisappearingViewsFirst = true
private var portalFragment: PortalFragment? = null
private var webVitalsCallback: ((WebVitals.Metric, Long) -> Unit)? = null
private var onBridgeAvailable: ((bridge: Bridge) -> Unit)? = null

/**
Expand All @@ -76,13 +75,10 @@ class PortalView : FrameLayout {
var tag: String? = null

constructor(context: Context) : super(context)
constructor(context: Context, portalId: String) : this(context, portalId, portalId+"_view", null, null)
constructor(context: Context, portalId: String, onBridgeAvailable: ((bridge: Bridge) -> Unit)) : this(context, portalId, portalId+"_view", onBridgeAvailable, null)
constructor(context: Context, portalId: String, webVitalsCallback: (metric: WebVitals.Metric, time: Long) -> Unit) : this(context, portalId, portalId+"_view", null, webVitalsCallback)
constructor(context: Context, portalId: String, onBridgeAvailable: ((bridge: Bridge) -> Unit), webVitalsCallback: (metric: WebVitals.Metric, time: Long) -> Unit) : this(context, portalId, portalId+"_view", onBridgeAvailable, webVitalsCallback)
constructor(context: Context, portalId: String) : this(context, portalId, portalId+"_view", null)
constructor(context: Context, portalId: String, onBridgeAvailable: (bridge: Bridge) -> Unit) : this(context, portalId, portalId+"_view", onBridgeAvailable)

constructor(context: Context, portalId: String, viewId: String, onBridgeAvailable: ((bridge: Bridge) -> Unit)?, webVitalsCallback: ((metric: WebVitals.Metric, long: Long) -> Unit)?) : super(context) {
this.webVitalsCallback = webVitalsCallback
constructor(context: Context, portalId: String, viewId: String, onBridgeAvailable: ((bridge: Bridge) -> Unit)?) : super(context) {
this.onBridgeAvailable = onBridgeAvailable
this.portalId = portalId
this.viewId = viewId
Expand All @@ -104,16 +100,6 @@ class PortalView : FrameLayout {
loadPortal(context, attrs)
}

constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, webVitalsCallback: (metric: WebVitals.Metric, long: Long) -> Unit) : super(
context,
attrs,
defStyleAttr
) {
this.webVitalsCallback = webVitalsCallback
readAttributes(context, attrs)
loadPortal(context, attrs)
}

/**
* Get the Portal Fragment used in the view.
*
Expand Down Expand Up @@ -185,7 +171,6 @@ class PortalView : FrameLayout {

portalFragment?.portal = portal
portalFragment?.onBridgeAvailable = this.onBridgeAvailable
portalFragment?.webVitalsCallback = this.webVitalsCallback
attrs?.let { attributeSet ->
portalFragment?.onInflate(context, attributeSet, null)
}
Expand Down
39 changes: 27 additions & 12 deletions IonicPortals/src/main/kotlin/io/ionic/portals/WebVitals.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
package io.ionic.portals

import android.webkit.JavascriptInterface
import android.webkit.WebView
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONObject

/**
* A class providing Web Vitals functionality. When Web Vitals metrics are desired, this class adds
* JavaScript to the Portals web view to support measuring the performance of the web application.
*
* @link https://web.dev/vitals/
* @property portalName the name of the Portal being analyzed
* @property callback a callback to act on reported Web Vitals data
*/
class WebVitals(val portalName: String, val callback: (Metric, Long) -> Unit) {
@CapacitorPlugin(name = "WebVitals")
class WebVitals(val callback: (String, Metric, Long) -> Unit): Plugin() {
override fun load() {
bridge.webView.addJavascriptInterface(this, "WebVitals")
bridge.webViewClient = object: BridgeWebViewClient(bridge) {
override fun onPageFinished(view: WebView?, url: String?) {
view?.loadUrl("javascript: $js")
super.onPageFinished(view, url)
}
}
}

/**
* Metrics supported by Portals Web Vitals.
*
Expand Down Expand Up @@ -42,16 +56,17 @@ class WebVitals(val portalName: String, val callback: (Metric, Long) -> Unit) {
* Original script contains:
* ```
* import { onFCP, onTTFB, onFID } from "web-vitals";
* onFCP(report => WebVitals.fcp(report.value));
* onFID(report => WebVitals.fid(report.value));
* onTTFB(report => WebVitals.ttfb(report.value));
* const portalName = JSON.parse(AndroidInitialContext.initialContext()).name;
* onFCP(report => WebVitals.fcp(portalName, report.value));
* onTTFB(report => WebVitals.ttfb(portalName, report.value));
* onFID(report => WebVitals.fid(portalName, report.value));
* ```
*
* Build command:
* esbuild ./index.js --bundle --minify --tree-shaking=true --platform=browser --outfile=dist/index.js
*/
val js = """
(()=>{var f,m,w,h;var F=-1,y=function(t){addEventListener("pageshow",function(e){e.persisted&&(F=e.timeStamp,t(e))},!0)},T=function(){return window.performance&&performance.getEntriesByType&&performance.getEntriesByType("navigation")[0]},E=function(){var t=T();return t&&t.activationStart||0},l=function(t,e){var i=T(),n="navigate";return F>=0?n="back-forward-cache":i&&(n=document.prerendering||E()>0?"prerender":document.wasDiscarded?"restore":i.type.replace(/_/g,"-")),{name:t,value:e===void 0?-1:e,rating:"good",delta:0,entries:[],id:"v3-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12),navigationType:n}},I=function(t,e,i){try{if(PerformanceObserver.supportedEntryTypes.includes(t)){var n=new PerformanceObserver(function(a){Promise.resolve().then(function(){e(a.getEntries())})});return n.observe(Object.assign({type:t,buffered:!0},i||{})),n}}catch{}},x=function(t,e){var i=function n(a){a.type!=="pagehide"&&document.visibilityState!=="hidden"||(t(a),e&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",i,!0),addEventListener("pagehide",i,!0)},v=function(t,e,i,n){var a,r;return function(o){e.value>=0&&(o||n)&&((r=e.value-(a||0))||a===void 0)&&(a=e.value,e.delta=r,e.rating=function(u,c){return u>c[1]?"poor":u>c[0]?"needs-improvement":"good"}(e.value,i),t(e))}},R=function(t){requestAnimationFrame(function(){return requestAnimationFrame(function(){return t()})})},C=function(t){document.prerendering?addEventListener("prerenderingchange",function(){return t()},!0):t()},d=-1,L=function(){return document.visibilityState!=="hidden"||document.prerendering?1/0:0},g=function(t){document.visibilityState==="hidden"&&d>-1&&(d=t.type==="visibilitychange"?t.timeStamp:0,H())},b=function(){addEventListener("visibilitychange",g,!0),addEventListener("prerenderingchange",g,!0)},H=function(){removeEventListener("visibilitychange",g,!0),removeEventListener("prerenderingchange",g,!0)},P=function(){return d<0&&(d=L(),b(),y(function(){setTimeout(function(){d=L(),b()},0)})),{get firstHiddenTime(){return d}}},A=function(t,e){e=e||{},C(function(){var i,n=[1800,3e3],a=P(),r=l("FCP"),o=I("paint",function(u){u.forEach(function(c){c.name==="first-contentful-paint"&&(o.disconnect(),c.startTime<a.firstHiddenTime&&(r.value=Math.max(c.startTime-E(),0),r.entries.push(c),i(!0)))})});o&&(i=v(t,r,n,e.reportAllChanges),y(function(u){r=l("FCP"),i=v(t,r,n,e.reportAllChanges),R(function(){r.value=performance.now()-u.timeStamp,i(!0)})}))})};var p={passive:!0,capture:!0},N=new Date,S=function(t,e){f||(f=e,m=t,w=new Date,M(removeEventListener),D())},D=function(){if(m>=0&&m<w-N){var t={entryType:"first-input",name:f.type,target:f.target,cancelable:f.cancelable,startTime:f.timeStamp,processingStart:f.timeStamp+m};h.forEach(function(e){e(t)}),h=[]}},O=function(t){if(t.cancelable){var e=(t.timeStamp>1e12?new Date:performance.now())-t.timeStamp;t.type=="pointerdown"?function(i,n){var a=function(){S(i,n),o()},r=function(){o()},o=function(){removeEventListener("pointerup",a,p),removeEventListener("pointercancel",r,p)};addEventListener("pointerup",a,p),addEventListener("pointercancel",r,p)}(e,t):S(e,t)}},M=function(t){["mousedown","keydown","touchstart","pointerdown"].forEach(function(e){return t(e,O,p)})},k=function(t,e){e=e||{},C(function(){var i,n=[100,300],a=P(),r=l("FID"),o=function(s){s.startTime<a.firstHiddenTime&&(r.value=s.processingStart-s.startTime,r.entries.push(s),i(!0))},u=function(s){s.forEach(o)},c=I("first-input",u);i=v(t,r,n,e.reportAllChanges),c&&x(function(){u(c.takeRecords()),c.disconnect()},!0),c&&y(function(){var s;r=l("FID"),i=v(t,r,n,e.reportAllChanges),h=[],m=-1,f=null,M(addEventListener),s=o,h.push(s),D()})})};var V=1/0;var q=function t(e){document.prerendering?C(function(){return t(e)}):document.readyState!=="complete"?addEventListener("load",function(){return t(e)},!0):setTimeout(e,0)},B=function(t,e){e=e||{};var i=[800,1800],n=l("TTFB"),a=v(t,n,i,e.reportAllChanges);q(function(){var r=T();if(r){var o=r.responseStart;if(o<=0||o>performance.now())return;n.value=Math.max(o-E(),0),n.entries=[r],a(!0),y(function(){n=l("TTFB",0),(a=v(t,n,i,e.reportAllChanges))(!0)})}})};A(t=>WebVitals.fcp(t.value));k(t=>WebVitals.fid(t.value));B(t=>WebVitals.ttfb(t.value));})();
(()=>{var f,m,F,h;var I=-1,y=function(t){addEventListener("pageshow",function(e){e.persisted&&(I=e.timeStamp,t(e))},!0)},T=function(){return window.performance&&performance.getEntriesByType&&performance.getEntriesByType("navigation")[0]},E=function(){var t=T();return t&&t.activationStart||0},l=function(t,e){var i=T(),n="navigate";return I>=0?n="back-forward-cache":i&&(n=document.prerendering||E()>0?"prerender":document.wasDiscarded?"restore":i.type.replace(/_/g,"-")),{name:t,value:e===void 0?-1:e,rating:"good",delta:0,entries:[],id:"v3-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12),navigationType:n}},P=function(t,e,i){try{if(PerformanceObserver.supportedEntryTypes.includes(t)){var n=new PerformanceObserver(function(a){Promise.resolve().then(function(){e(a.getEntries())})});return n.observe(Object.assign({type:t,buffered:!0},i||{})),n}}catch{}},N=function(t,e){var i=function n(a){a.type!=="pagehide"&&document.visibilityState!=="hidden"||(t(a),e&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",i,!0),addEventListener("pagehide",i,!0)},v=function(t,e,i,n){var a,r;return function(o){e.value>=0&&(o||n)&&((r=e.value-(a||0))||a===void 0)&&(a=e.value,e.delta=r,e.rating=function(u,c){return u>c[1]?"poor":u>c[0]?"needs-improvement":"good"}(e.value,i),t(e))}},R=function(t){requestAnimationFrame(function(){return requestAnimationFrame(function(){return t()})})},C=function(t){document.prerendering?addEventListener("prerenderingchange",function(){return t()},!0):t()},d=-1,b=function(){return document.visibilityState!=="hidden"||document.prerendering?1/0:0},g=function(t){document.visibilityState==="hidden"&&d>-1&&(d=t.type==="visibilitychange"?t.timeStamp:0,H())},S=function(){addEventListener("visibilitychange",g,!0),addEventListener("prerenderingchange",g,!0)},H=function(){removeEventListener("visibilitychange",g,!0),removeEventListener("prerenderingchange",g,!0)},A=function(){return d<0&&(d=b(),S(),y(function(){setTimeout(function(){d=b(),S()},0)})),{get firstHiddenTime(){return d}}},D=function(t,e){e=e||{},C(function(){var i,n=[1800,3e3],a=A(),r=l("FCP"),o=P("paint",function(u){u.forEach(function(c){c.name==="first-contentful-paint"&&(o.disconnect(),c.startTime<a.firstHiddenTime&&(r.value=Math.max(c.startTime-E(),0),r.entries.push(c),i(!0)))})});o&&(i=v(t,r,n,e.reportAllChanges),y(function(u){r=l("FCP"),i=v(t,r,n,e.reportAllChanges),R(function(){r.value=performance.now()-u.timeStamp,i(!0)})}))})};var p={passive:!0,capture:!0},O=new Date,w=function(t,e){f||(f=e,m=t,F=new Date,k(removeEventListener),M())},M=function(){if(m>=0&&m<F-O){var t={entryType:"first-input",name:f.type,target:f.target,cancelable:f.cancelable,startTime:f.timeStamp,processingStart:f.timeStamp+m};h.forEach(function(e){e(t)}),h=[]}},q=function(t){if(t.cancelable){var e=(t.timeStamp>1e12?new Date:performance.now())-t.timeStamp;t.type=="pointerdown"?function(i,n){var a=function(){w(i,n),o()},r=function(){o()},o=function(){removeEventListener("pointerup",a,p),removeEventListener("pointercancel",r,p)};addEventListener("pointerup",a,p),addEventListener("pointercancel",r,p)}(e,t):w(e,t)}},k=function(t){["mousedown","keydown","touchstart","pointerdown"].forEach(function(e){return t(e,q,p)})},x=function(t,e){e=e||{},C(function(){var i,n=[100,300],a=A(),r=l("FID"),o=function(s){s.startTime<a.firstHiddenTime&&(r.value=s.processingStart-s.startTime,r.entries.push(s),i(!0))},u=function(s){s.forEach(o)},c=P("first-input",u);i=v(t,r,n,e.reportAllChanges),c&&N(function(){u(c.takeRecords()),c.disconnect()},!0),c&&y(function(){var s;r=l("FID"),i=v(t,r,n,e.reportAllChanges),h=[],m=-1,f=null,k(addEventListener),s=o,h.push(s),M()})})};var W=1/0;var V=function t(e){document.prerendering?C(function(){return t(e)}):document.readyState!=="complete"?addEventListener("load",function(){return t(e)},!0):setTimeout(e,0)},B=function(t,e){e=e||{};var i=[800,1800],n=l("TTFB"),a=v(t,n,i,e.reportAllChanges);V(function(){var r=T();if(r){var o=r.responseStart;if(o<=0||o>performance.now())return;n.value=Math.max(o-E(),0),n.entries=[r],a(!0),y(function(){n=l("TTFB",0),(a=v(t,n,i,e.reportAllChanges))(!0)})}})};var L=JSON.parse(AndroidInitialContext.initialContext()).name;D(t=>WebVitals.fcp(L,t.value));B(t=>WebVitals.ttfb(L,t.value));x(t=>WebVitals.fid(L,t.value));})();
""".trimIndent()

/**
Expand All @@ -60,8 +75,8 @@ class WebVitals(val portalName: String, val callback: (Metric, Long) -> Unit) {
* @param time Time in milliseconds when FCP is measured
*/
@JavascriptInterface
fun fcp(time: Long) {
callback(Metric.FCP, time)
fun fcp(portalName: String, time: Long) {
callback(portalName, Metric.FCP, time)
}

/**
Expand All @@ -70,8 +85,8 @@ class WebVitals(val portalName: String, val callback: (Metric, Long) -> Unit) {
* @param time Time in milliseconds when FID is measured
*/
@JavascriptInterface
fun fid(time: Long) {
callback(Metric.FID, time)
fun fid(portalName: String, time: Long) {
callback(portalName, Metric.FID, time)
}

/**
Expand All @@ -80,7 +95,7 @@ class WebVitals(val portalName: String, val callback: (Metric, Long) -> Unit) {
* @param time Time in milliseconds when TTFB is measured
*/
@JavascriptInterface
fun ttfb(time: Long) {
callback(Metric.TTFB, time)
fun ttfb(portalName: String, time: Long) {
callback(portalName, Metric.TTFB, time)
}
}
15 changes: 15 additions & 0 deletions vitals/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## Building
```bash
npm install
npm run build
```

## Updating JS string in source
If updating web-vitals is necessary run the build instructions above and then copy the output:

```bash
cat dist/index.js | pbcopy
```

Then paste the contents into the script source in `WebVitals.kit` in the `js` property.
This is not anticipated to be updated frequently, so no automation or code generation has been done for this.
Loading

0 comments on commit 151b1c4

Please sign in to comment.