Skip to content

Commit

Permalink
Add dynamic LCP priority worker logic
Browse files Browse the repository at this point in the history
  • Loading branch information
philipwalton committed Dec 29, 2022
1 parent 780ead3 commit a5a02f5
Show file tree
Hide file tree
Showing 5 changed files with 214 additions and 12 deletions.
24 changes: 22 additions & 2 deletions assets/javascript/log.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {uuid} from './utils/uuid';
* implementation. This allows you to create a segment or view filter
* that isolates only data captured with the most recent tracking changes.
*/
const MEASUREMENT_VERSION = 91;
const MEASUREMENT_VERSION = 92;

/**
* A 13-digit, random identifier for the current page.
Expand Down Expand Up @@ -282,12 +282,32 @@ const trackINP = async () => {
const trackLCP = async () => {
onLCP(
(metric) => {
let dynamicFetchPriority;

// If the LCP element is an image, send a hint for the next visitor.
const {element, lcpEntry} = metric.attribution;
if (lcpEntry?.url && lcpEntry.element?.tagName.toLowerCase() === 'img') {
const elementWithPriority = document.querySelector('[fetchpriority]');
if (elementWithPriority) {
dynamicFetchPriority =
elementWithPriority === lcpEntry.element ? 'hit' : 'miss';
}
navigator.sendBeacon(
'/hint',
JSON.stringify({
path: originalPathname,
selector: element,
})
);
}

log.event(metric.name, {
value: metric.delta,
metric_rating: metric.rating,
metric_value: metric.value,
debug_target: metric.attribution.element || '(not set)',
debug_target: element || '(not set)',
debug_url: metric.attribution.url,
debug_dfp: dynamicFetchPriority,
debug_ttfb: metric.attribution.timeToFirstByte,
debug_rld: metric.attribution.resourceLoadDelay,
debug_rlt: metric.attribution.resourceLoadTime,
Expand Down
93 changes: 93 additions & 0 deletions test/worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {strict as assert} from 'assert';
import fetch from 'node-fetch';

const urlWithLCPImage =
'/articles/my-challenge-to-the-web-performance-community/';

const urlWithoutLCPImage = '/articles/cascading-cache-invalidation/';

describe('worker', function () {
describe('priority hints', function () {
beforeEach(async () => {
// Delete the cache by passing an empty selector.
// TODO: figure out a better way to do this. There doesn't seem to
// currently be a way to clear KV store data locally via wrangler.
for (const url of [urlWithLCPImage, urlWithLCPImage]) {
await fetch(`http://127.0.0.1:3000/hint`, {
method: 'POST',
body: JSON.stringify({
path: url,
selector: '',
}),
});
}
});

it('should apply priority hints to prior LCP images', async () => {
await browser.url(urlWithLCPImage);

const fp1 = await browser.execute(() => {
return document.querySelector('img').getAttribute('fetchpriority');
});

// Wait until the hint has been sent.
await browser.executeAsync((done) => {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.endsWith('/hint')) {
done();
}
}
}).observe({type: 'resource', buffered: true});
});

await browser.url('/__reset__');

// Wait until the SW has unregistered.
await browser.waitUntil(async () => {
return await browser.execute(() => {
return window.__ready__ === true;
});
});

await browser.url(urlWithLCPImage);

const fp2 = await browser.execute(() => {
return document.querySelector('img').getAttribute('fetchpriority');
});

assert.strictEqual(fp1, null);
assert.strictEqual(fp2, 'high');
});

it('should not apply priority hints to prior non-LCP images', async () => {
await browser.url(urlWithoutLCPImage);

const fp1 = await browser.execute(() => {
return document.querySelector('img').getAttribute('fetchpriority');
});

// No hint should be sent for this page, but we need to wait a bit
// to ensure that it doesn't happen.
await browser.pause(2000);

await browser.url('/__reset__');

// Wait until the SW has unregistered.
await browser.waitUntil(async () => {
return await browser.execute(() => {
return window.__ready__ === true;
});
});

await browser.url(urlWithoutLCPImage);

const fp2 = await browser.execute(() => {
return document.querySelector('img').getAttribute('fetchpriority');
});

assert.strictEqual(fp1, null);
assert.strictEqual(fp2, null);
});
});
});
49 changes: 39 additions & 10 deletions worker/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {applyExperiment, getExperiment} from './lib/experiments.js';
import {
addPriorityHints,
getPriorityHintKey,
logPriorityHint,
} from './lib/performance.js';
import {getRedirectPath} from './lib/redirects.js';
import {matchesRoute} from './lib/router.js';

Expand All @@ -17,6 +22,18 @@ function getXIDFromCookie(cookie) {
return cookie.match(/(?:^|;) *xid=(\.\d+) *(?:;|$)/) && RegExp.$1;
}

/**
* Removes a trailing `index.content.html` from a URL path.
* @param {string} path
* @returns {string}
*/
function normalizePath(path) {
if (path.endsWith('index.content.html')) {
return path.slice(0, -18);
}
return path;
}

/**
* @param {Request} request
* @param {Response} response
Expand Down Expand Up @@ -71,23 +88,31 @@ async function handleRequest({request, url, startTime, vars}) {

const experiment = getExperiment(xid);

const response = await fetch(url.href, {
body: request.body,
headers: request.headers,
method: request.method,
redirect: request.redirect,
cf: {
cacheEverything: vars.ENV === 'production',
cacheTtlByStatus: {'200-299': 604800, '400-599': 0},
},
});
const [response, priorityHintsSelector] = await Promise.all([
fetch(url.href, {
body: request.body,
headers: request.headers,
method: request.method,
redirect: request.redirect,
cf: {
cacheEverything: vars.ENV === 'production',
cacheTtlByStatus: {'200-299': 604800, '400-599': 0},
},
}),
vars.PRIORITY_HINTS.get(
getPriorityHintKey(request, normalizePath(url.pathname))
),
]);

const clone = new Response(response.body, response);

setXIDToCookie(xid, clone);
addServerTimingHeaders(clone, startTime);

const rewriter = new HTMLRewriter();
if (priorityHintsSelector) {
addPriorityHints(rewriter, priorityHintsSelector);
}
if (experiment) {
applyExperiment(experiment, rewriter);
}
Expand All @@ -104,6 +129,10 @@ export default {
const startTime = Date.now();
const url = new URL(request.url);

if (url.pathname === '/hint' && request.method === 'POST') {
return logPriorityHint(request, vars.PRIORITY_HINTS);
}

// Return early if no route matches.
// Note: this should never happen in production.
if (!matchesRoute({request, url})) {
Expand Down
55 changes: 55 additions & 0 deletions worker/lib/performance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
class PriorityHintsTransform {
#applied = false;
element(element) {
if (!this.#applied) {
element.setAttribute('fetchpriority', 'high');
this.#applied = true;
}
}
}

/**
* @param {string} experiment
* @param {HTMLRewriter} rewriter
*/
export function addPriorityHints(rewriter, selector) {
rewriter.on(selector, new PriorityHintsTransform());
}

/**
* @param {Request} request
* @param {string} path
* @returns {string}
*/
export function getPriorityHintKey(request, path) {
const device =
request.headers.get('sec-ch-ua-mobile') === '?1' ? 'mobile' : 'desktop';

// URL-encode the path because wrangler doesn't handle slashes when
// running locally (it treats them as directory separators).
return `${device}:${encodeURIComponent(path)}`;
}

/**
* @param {Request} request
* @param {Object} store
*/
async function storePriorityHints(request, store) {
const {path, selector} = await request.json();
const key = getPriorityHintKey(request, path);

const storedSelector = await store.get(key);
if (selector !== storedSelector) {
await store.put(key, selector);
}
}

/**
* @param {Request} request
* @param {Object} store
* @returns {Response}
*/
export async function logPriorityHint(request, store) {
await storePriorityHints(request, store);
return new Response(); // Empty 200.
}
5 changes: 5 additions & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ account_id = "833aa47653f1a867bf6f206ed576cd2f"
compatibility_date = "2022-09-24"
workers_dev = true

kv_namespaces = [
{ binding = "PRIORITY_HINTS", id = "7fdf8d6f35d042a5b21b77ab6a56028e", preview_id = "40156d62dc48468aa5e21fe01ed5b01e" }
]

[dev]
host = "localhost:3001" # Origin server
port = 3000 # Worker server
Expand All @@ -21,6 +25,7 @@ routes = [
"philipwalton.com/shell*",
"philipwalton.com/articles*",
"philipwalton.com/about*",
"philipwalton.com/hint",
]
[env.production.vars]
ENV = "production"

0 comments on commit a5a02f5

Please sign in to comment.