Skip to content

Commit a5a02f5

Browse files
committed
Add dynamic LCP priority worker logic
1 parent 780ead3 commit a5a02f5

5 files changed

Lines changed: 214 additions & 12 deletions

File tree

assets/javascript/log.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {uuid} from './utils/uuid';
1616
* implementation. This allows you to create a segment or view filter
1717
* that isolates only data captured with the most recent tracking changes.
1818
*/
19-
const MEASUREMENT_VERSION = 91;
19+
const MEASUREMENT_VERSION = 92;
2020

2121
/**
2222
* A 13-digit, random identifier for the current page.
@@ -282,12 +282,32 @@ const trackINP = async () => {
282282
const trackLCP = async () => {
283283
onLCP(
284284
(metric) => {
285+
let dynamicFetchPriority;
286+
287+
// If the LCP element is an image, send a hint for the next visitor.
288+
const {element, lcpEntry} = metric.attribution;
289+
if (lcpEntry?.url && lcpEntry.element?.tagName.toLowerCase() === 'img') {
290+
const elementWithPriority = document.querySelector('[fetchpriority]');
291+
if (elementWithPriority) {
292+
dynamicFetchPriority =
293+
elementWithPriority === lcpEntry.element ? 'hit' : 'miss';
294+
}
295+
navigator.sendBeacon(
296+
'/hint',
297+
JSON.stringify({
298+
path: originalPathname,
299+
selector: element,
300+
})
301+
);
302+
}
303+
285304
log.event(metric.name, {
286305
value: metric.delta,
287306
metric_rating: metric.rating,
288307
metric_value: metric.value,
289-
debug_target: metric.attribution.element || '(not set)',
308+
debug_target: element || '(not set)',
290309
debug_url: metric.attribution.url,
310+
debug_dfp: dynamicFetchPriority,
291311
debug_ttfb: metric.attribution.timeToFirstByte,
292312
debug_rld: metric.attribution.resourceLoadDelay,
293313
debug_rlt: metric.attribution.resourceLoadTime,

test/worker.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {strict as assert} from 'assert';
2+
import fetch from 'node-fetch';
3+
4+
const urlWithLCPImage =
5+
'/articles/my-challenge-to-the-web-performance-community/';
6+
7+
const urlWithoutLCPImage = '/articles/cascading-cache-invalidation/';
8+
9+
describe('worker', function () {
10+
describe('priority hints', function () {
11+
beforeEach(async () => {
12+
// Delete the cache by passing an empty selector.
13+
// TODO: figure out a better way to do this. There doesn't seem to
14+
// currently be a way to clear KV store data locally via wrangler.
15+
for (const url of [urlWithLCPImage, urlWithLCPImage]) {
16+
await fetch(`http://127.0.0.1:3000/hint`, {
17+
method: 'POST',
18+
body: JSON.stringify({
19+
path: url,
20+
selector: '',
21+
}),
22+
});
23+
}
24+
});
25+
26+
it('should apply priority hints to prior LCP images', async () => {
27+
await browser.url(urlWithLCPImage);
28+
29+
const fp1 = await browser.execute(() => {
30+
return document.querySelector('img').getAttribute('fetchpriority');
31+
});
32+
33+
// Wait until the hint has been sent.
34+
await browser.executeAsync((done) => {
35+
new PerformanceObserver((list) => {
36+
for (const entry of list.getEntries()) {
37+
if (entry.name.endsWith('/hint')) {
38+
done();
39+
}
40+
}
41+
}).observe({type: 'resource', buffered: true});
42+
});
43+
44+
await browser.url('/__reset__');
45+
46+
// Wait until the SW has unregistered.
47+
await browser.waitUntil(async () => {
48+
return await browser.execute(() => {
49+
return window.__ready__ === true;
50+
});
51+
});
52+
53+
await browser.url(urlWithLCPImage);
54+
55+
const fp2 = await browser.execute(() => {
56+
return document.querySelector('img').getAttribute('fetchpriority');
57+
});
58+
59+
assert.strictEqual(fp1, null);
60+
assert.strictEqual(fp2, 'high');
61+
});
62+
63+
it('should not apply priority hints to prior non-LCP images', async () => {
64+
await browser.url(urlWithoutLCPImage);
65+
66+
const fp1 = await browser.execute(() => {
67+
return document.querySelector('img').getAttribute('fetchpriority');
68+
});
69+
70+
// No hint should be sent for this page, but we need to wait a bit
71+
// to ensure that it doesn't happen.
72+
await browser.pause(2000);
73+
74+
await browser.url('/__reset__');
75+
76+
// Wait until the SW has unregistered.
77+
await browser.waitUntil(async () => {
78+
return await browser.execute(() => {
79+
return window.__ready__ === true;
80+
});
81+
});
82+
83+
await browser.url(urlWithoutLCPImage);
84+
85+
const fp2 = await browser.execute(() => {
86+
return document.querySelector('img').getAttribute('fetchpriority');
87+
});
88+
89+
assert.strictEqual(fp1, null);
90+
assert.strictEqual(fp2, null);
91+
});
92+
});
93+
});

worker/index.js

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import {applyExperiment, getExperiment} from './lib/experiments.js';
2+
import {
3+
addPriorityHints,
4+
getPriorityHintKey,
5+
logPriorityHint,
6+
} from './lib/performance.js';
27
import {getRedirectPath} from './lib/redirects.js';
38
import {matchesRoute} from './lib/router.js';
49

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

25+
/**
26+
* Removes a trailing `index.content.html` from a URL path.
27+
* @param {string} path
28+
* @returns {string}
29+
*/
30+
function normalizePath(path) {
31+
if (path.endsWith('index.content.html')) {
32+
return path.slice(0, -18);
33+
}
34+
return path;
35+
}
36+
2037
/**
2138
* @param {Request} request
2239
* @param {Response} response
@@ -71,23 +88,31 @@ async function handleRequest({request, url, startTime, vars}) {
7188

7289
const experiment = getExperiment(xid);
7390

74-
const response = await fetch(url.href, {
75-
body: request.body,
76-
headers: request.headers,
77-
method: request.method,
78-
redirect: request.redirect,
79-
cf: {
80-
cacheEverything: vars.ENV === 'production',
81-
cacheTtlByStatus: {'200-299': 604800, '400-599': 0},
82-
},
83-
});
91+
const [response, priorityHintsSelector] = await Promise.all([
92+
fetch(url.href, {
93+
body: request.body,
94+
headers: request.headers,
95+
method: request.method,
96+
redirect: request.redirect,
97+
cf: {
98+
cacheEverything: vars.ENV === 'production',
99+
cacheTtlByStatus: {'200-299': 604800, '400-599': 0},
100+
},
101+
}),
102+
vars.PRIORITY_HINTS.get(
103+
getPriorityHintKey(request, normalizePath(url.pathname))
104+
),
105+
]);
84106

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

87109
setXIDToCookie(xid, clone);
88110
addServerTimingHeaders(clone, startTime);
89111

90112
const rewriter = new HTMLRewriter();
113+
if (priorityHintsSelector) {
114+
addPriorityHints(rewriter, priorityHintsSelector);
115+
}
91116
if (experiment) {
92117
applyExperiment(experiment, rewriter);
93118
}
@@ -104,6 +129,10 @@ export default {
104129
const startTime = Date.now();
105130
const url = new URL(request.url);
106131

132+
if (url.pathname === '/hint' && request.method === 'POST') {
133+
return logPriorityHint(request, vars.PRIORITY_HINTS);
134+
}
135+
107136
// Return early if no route matches.
108137
// Note: this should never happen in production.
109138
if (!matchesRoute({request, url})) {

worker/lib/performance.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
class PriorityHintsTransform {
2+
#applied = false;
3+
element(element) {
4+
if (!this.#applied) {
5+
element.setAttribute('fetchpriority', 'high');
6+
this.#applied = true;
7+
}
8+
}
9+
}
10+
11+
/**
12+
* @param {string} experiment
13+
* @param {HTMLRewriter} rewriter
14+
*/
15+
export function addPriorityHints(rewriter, selector) {
16+
rewriter.on(selector, new PriorityHintsTransform());
17+
}
18+
19+
/**
20+
* @param {Request} request
21+
* @param {string} path
22+
* @returns {string}
23+
*/
24+
export function getPriorityHintKey(request, path) {
25+
const device =
26+
request.headers.get('sec-ch-ua-mobile') === '?1' ? 'mobile' : 'desktop';
27+
28+
// URL-encode the path because wrangler doesn't handle slashes when
29+
// running locally (it treats them as directory separators).
30+
return `${device}:${encodeURIComponent(path)}`;
31+
}
32+
33+
/**
34+
* @param {Request} request
35+
* @param {Object} store
36+
*/
37+
async function storePriorityHints(request, store) {
38+
const {path, selector} = await request.json();
39+
const key = getPriorityHintKey(request, path);
40+
41+
const storedSelector = await store.get(key);
42+
if (selector !== storedSelector) {
43+
await store.put(key, selector);
44+
}
45+
}
46+
47+
/**
48+
* @param {Request} request
49+
* @param {Object} store
50+
* @returns {Response}
51+
*/
52+
export async function logPriorityHint(request, store) {
53+
await storePriorityHints(request, store);
54+
return new Response(); // Empty 200.
55+
}

wrangler.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ account_id = "833aa47653f1a867bf6f206ed576cd2f"
55
compatibility_date = "2022-09-24"
66
workers_dev = true
77

8+
kv_namespaces = [
9+
{ binding = "PRIORITY_HINTS", id = "7fdf8d6f35d042a5b21b77ab6a56028e", preview_id = "40156d62dc48468aa5e21fe01ed5b01e" }
10+
]
11+
812
[dev]
913
host = "localhost:3001" # Origin server
1014
port = 3000 # Worker server
@@ -21,6 +25,7 @@ routes = [
2125
"philipwalton.com/shell*",
2226
"philipwalton.com/articles*",
2327
"philipwalton.com/about*",
28+
"philipwalton.com/hint",
2429
]
2530
[env.production.vars]
2631
ENV = "production"

0 commit comments

Comments
 (0)