buninu-browser 0.2.0 → 0.2.1
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.
- package/lib/cdp-server.js +3 -1
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +57 -12
- package/lib/layout/text-layout.js +179 -15
- package/lib/network/network-service.js +176 -43
- package/lib/paint/display-list.js +23 -8
- package/lib/render-tree/index.js +45 -0
- package/lib/renderer/bindings.js +4 -4
- package/lib/renderer/host.js +1 -1
- package/lib/renderer/page-realm.js +2 -2
- package/lib/renderer/page-renderer.js +166 -204
- package/lib/style/computed-style.js +301 -220
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/package.json +1 -1
- package/test/computed-style.test.js +61 -1
- package/test/headless-shell.test.js +29 -0
- package/test/log.test.js +4 -1
- package/test/network-service.test.js +67 -0
- package/test/page-scripts.test.js +49 -0
- package/test/render-tree.test.js +22 -1
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/text-layout.test.js +69 -1
|
@@ -15,6 +15,7 @@ import { fetchResolved, HostResolver } from "./resolver.js";
|
|
|
15
15
|
|
|
16
16
|
const MAX_REDIRECTS = 20;
|
|
17
17
|
const MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
18
|
+
const MAX_CACHED_SUBRESOURCES = 256;
|
|
18
19
|
const SIMPLE_METHODS = new Set(["GET", "HEAD", "POST"]);
|
|
19
20
|
const FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]);
|
|
20
21
|
// Fetch §2.2.2 CORS-safelisted request headers (value checks simplified).
|
|
@@ -29,12 +30,23 @@ const FORBIDDEN_REQUEST_HEADERS = new Set([
|
|
|
29
30
|
const SAFELISTED_RESPONSE_HEADERS = new Set([
|
|
30
31
|
"cache-control", "content-language", "content-length", "content-type", "expires", "last-modified", "pragma",
|
|
31
32
|
]);
|
|
33
|
+
// Requests in flight to one host at a time, as browsers allow over HTTP/1.1.
|
|
34
|
+
const MAX_REQUESTS_PER_HOST = 6;
|
|
35
|
+
// Which waiting request to a busy host goes next (lower first): documents,
|
|
36
|
+
// then what blocks rendering or that a script waits for, then the rest.
|
|
37
|
+
const PRIORITY = { document: 0, stylesheet: 1, fetch: 1, script: 2, metadata: 2, image: 3, video: 3, audio: 3 };
|
|
32
38
|
|
|
33
39
|
export class NetworkService {
|
|
34
40
|
#fetch;
|
|
35
41
|
#resolver;
|
|
36
42
|
#cookies;
|
|
37
43
|
#userAgent;
|
|
44
|
+
// Browser-process HTTP cache. Entries are partitioned by the request
|
|
45
|
+
// credentials that affect a subresource response; concurrent identical
|
|
46
|
+
// requests share one transfer.
|
|
47
|
+
#subresourceCache = new Map();
|
|
48
|
+
#subresourceInflight = new Map();
|
|
49
|
+
#hosts = new HostThrottle(MAX_REQUESTS_PER_HOST);
|
|
38
50
|
|
|
39
51
|
/**
|
|
40
52
|
* `resolver` looks host names up itself (see resolver.js); it is on for the
|
|
@@ -55,21 +67,63 @@ export class NetworkService {
|
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
set userAgent(value) {
|
|
58
|
-
|
|
70
|
+
const next = typeof value === "string" && value ? value : null;
|
|
71
|
+
if (next !== this.#userAgent) this.#subresourceCache.clear();
|
|
72
|
+
this.#userAgent = next;
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
/** A top-level document load. */
|
|
62
76
|
async navigate(url, { onResponse } = {}) {
|
|
63
77
|
return this.#send({ url, method: "GET", headers: [], body: null }, {
|
|
64
78
|
credentials: "include", site: null, topLevelNavigation: true,
|
|
65
|
-
}, { onResponse });
|
|
79
|
+
}, { onResponse, priority: PRIORITY.document });
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
/**
|
|
83
|
+
* A subresource the renderer needs to render (no-cors, credentials included).
|
|
84
|
+
* `kind` ("stylesheet", "script", "image"...) decides its turn at a busy host.
|
|
85
|
+
*/
|
|
86
|
+
async subresource(url, documentUrl, kind = "metadata") {
|
|
87
|
+
const site = siteOf(documentUrl);
|
|
88
|
+
const cookie = this.#cookies.header(url, { site });
|
|
89
|
+
const key = `${url}\n${site ?? ""}\n${cookie}\n${this.#userAgent ?? ""}`;
|
|
90
|
+
const cached = this.#subresourceCache.get(key);
|
|
91
|
+
if (cached && cached.expires > Date.now()) return cached.result;
|
|
92
|
+
const pending = this.#subresourceInflight.get(key);
|
|
93
|
+
if (pending) return pending;
|
|
94
|
+
const request = (async () => {
|
|
95
|
+
const headers = cached?.etag ? [["if-none-match", cached.etag]] : [];
|
|
96
|
+
let result = await this.#send({ url, method: "GET", headers, body: null }, {
|
|
97
|
+
credentials: "include", site,
|
|
98
|
+
}, { priority: PRIORITY[kind] ?? PRIORITY.metadata });
|
|
99
|
+
if (result.status === 304 && cached) {
|
|
100
|
+
const rawHeaders = [...new Map([...cached.result.rawHeaders, ...result.rawHeaders])];
|
|
101
|
+
result = {
|
|
102
|
+
...cached.result,
|
|
103
|
+
rawHeaders,
|
|
104
|
+
headers: [...new Map([...cached.result.headers, ...result.headers])],
|
|
105
|
+
};
|
|
106
|
+
const policy = cachePolicy(rawHeaders);
|
|
107
|
+
cached.expires = policy.expires;
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
const policy = cachePolicy(result.rawHeaders);
|
|
111
|
+
if (result.ok && policy.cacheable) {
|
|
112
|
+
if (!this.#subresourceCache.has(key) && this.#subresourceCache.size >= MAX_CACHED_SUBRESOURCES) {
|
|
113
|
+
this.#subresourceCache.delete(this.#subresourceCache.keys().next().value);
|
|
114
|
+
}
|
|
115
|
+
this.#subresourceCache.set(key, { result, expires: policy.expires, etag: policy.etag });
|
|
116
|
+
} else {
|
|
117
|
+
this.#subresourceCache.delete(key);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
})();
|
|
121
|
+
this.#subresourceInflight.set(key, request);
|
|
122
|
+
try {
|
|
123
|
+
return await request;
|
|
124
|
+
} finally {
|
|
125
|
+
if (this.#subresourceInflight.get(key) === request) this.#subresourceInflight.delete(key);
|
|
126
|
+
}
|
|
73
127
|
}
|
|
74
128
|
|
|
75
129
|
/**
|
|
@@ -131,19 +185,19 @@ export class NetworkService {
|
|
|
131
185
|
|
|
132
186
|
if (sameOrigin) {
|
|
133
187
|
return this.#send({ url: url.href, method, headers, body: request.body ?? null }, context, {
|
|
134
|
-
redirect: request.redirect ?? "follow", corsOrigin: null,
|
|
188
|
+
redirect: request.redirect ?? "follow", corsOrigin: null, priority: PRIORITY.fetch,
|
|
135
189
|
});
|
|
136
190
|
}
|
|
137
191
|
if (mode === "no-cors") {
|
|
138
192
|
const response = await this.#send({ url: url.href, method, headers: headers.filter(isSafelistedHeader), body: request.body ?? null },
|
|
139
|
-
context, { redirect: "follow", corsOrigin: null });
|
|
193
|
+
context, { redirect: "follow", corsOrigin: null, priority: PRIORITY.fetch });
|
|
140
194
|
return { ...response, type: "opaque", status: 0, statusText: "", url: "", headers: [], body: new Uint8Array(0), redirected: false };
|
|
141
195
|
}
|
|
142
196
|
// CORS: preflight unless the request is "simple" (Fetch §4.8).
|
|
143
197
|
const needsPreflight = !SIMPLE_METHODS.has(method) || headers.some((header) => !isSafelistedHeader(header));
|
|
144
198
|
if (needsPreflight) await this.#preflight(url, method, headers, origin, context);
|
|
145
199
|
const response = await this.#send({ url: url.href, method, headers, body: request.body ?? null }, context, {
|
|
146
|
-
redirect: request.redirect ?? "follow", corsOrigin: origin,
|
|
200
|
+
redirect: request.redirect ?? "follow", corsOrigin: origin, priority: PRIORITY.fetch,
|
|
147
201
|
});
|
|
148
202
|
const allowed = corsAllowed(response.rawHeaders, origin, context.credentials === "include");
|
|
149
203
|
if (!allowed) throw new TypeError(`CORS: ${url.href} did not allow origin ${origin}`);
|
|
@@ -159,17 +213,23 @@ export class NetworkService {
|
|
|
159
213
|
|
|
160
214
|
async #preflight(url, method, headers, origin, context) {
|
|
161
215
|
const requestHeaders = headers.filter((entry) => !isSafelistedHeader(entry)).map(([name]) => name).sort();
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
216
|
+
const release = await this.#hosts.acquire(url.href, PRIORITY.fetch);
|
|
217
|
+
let response;
|
|
218
|
+
try {
|
|
219
|
+
response = await this.#request(url.href, {
|
|
220
|
+
method: "OPTIONS",
|
|
221
|
+
redirect: "manual",
|
|
222
|
+
headers: {
|
|
223
|
+
origin,
|
|
224
|
+
"access-control-request-method": method,
|
|
225
|
+
...(requestHeaders.length ? { "access-control-request-headers": requestHeaders.join(",") } : {}),
|
|
226
|
+
...(this.#userAgent ? { "user-agent": this.#userAgent } : {}),
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
await response.arrayBuffer().catch(() => {});
|
|
230
|
+
} finally {
|
|
231
|
+
release();
|
|
232
|
+
}
|
|
173
233
|
const raw = [...response.headers];
|
|
174
234
|
if (response.status < 200 || response.status > 299 || !corsAllowed(raw, origin, context.credentials === "include")) {
|
|
175
235
|
throw new TypeError(`CORS preflight for ${url.href} was rejected`);
|
|
@@ -191,8 +251,11 @@ export class NetworkService {
|
|
|
191
251
|
return this.#resolver ? fetchResolved(this.#fetch, this.#resolver, url, init) : this.#fetch(url, init);
|
|
192
252
|
}
|
|
193
253
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Sends a request, following redirects by hand so cookies apply to every hop.
|
|
256
|
+
* Each hop holds one of its host's request slots until its body is read.
|
|
257
|
+
*/
|
|
258
|
+
async #send(request, context, { redirect = "follow", corsOrigin = null, onResponse = null, priority = PRIORITY.metadata } = {}) {
|
|
196
259
|
let { url, method, body } = request;
|
|
197
260
|
const baseHeaders = request.headers;
|
|
198
261
|
let redirected = false;
|
|
@@ -206,28 +269,33 @@ export class NetworkService {
|
|
|
206
269
|
});
|
|
207
270
|
if (cookie) headers.set("cookie", cookie);
|
|
208
271
|
}
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
272
|
+
const release = await this.#hosts.acquire(url, priority);
|
|
273
|
+
try {
|
|
274
|
+
const response = await this.#request(url, { method, headers, body, redirect: "manual" });
|
|
275
|
+
if (context.credentials === "include") this.#cookies.store(url, response.headers.getSetCookie?.() ?? []);
|
|
276
|
+
const location = response.headers.get("location");
|
|
277
|
+
if ([301, 302, 303, 307, 308].includes(response.status) && location) {
|
|
278
|
+
await response.arrayBuffer().catch(() => {});
|
|
279
|
+
if (redirect === "error") throw new TypeError("Redirect was not allowed");
|
|
280
|
+
if (redirect === "manual") return await this.#result(response, url, redirected, "opaqueredirect");
|
|
281
|
+
const next = new URL(location, url);
|
|
282
|
+
if (!["http:", "https:"].includes(next.protocol)) throw new TypeError(`Redirect to ${next.protocol} is not allowed`);
|
|
283
|
+
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) {
|
|
284
|
+
method = method === "HEAD" ? "HEAD" : "GET";
|
|
285
|
+
body = null;
|
|
286
|
+
}
|
|
287
|
+
url = next.href;
|
|
288
|
+
redirected = true;
|
|
289
|
+
continue;
|
|
221
290
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
291
|
+
if (onResponse) {
|
|
292
|
+
const handled = await onResponse(response, url);
|
|
293
|
+
if (handled) return handled;
|
|
294
|
+
}
|
|
295
|
+
return await this.#result(response, url, redirected, "basic");
|
|
296
|
+
} finally {
|
|
297
|
+
release();
|
|
229
298
|
}
|
|
230
|
-
return this.#result(response, url, redirected, "basic");
|
|
231
299
|
}
|
|
232
300
|
throw new TypeError("Too many redirects");
|
|
233
301
|
}
|
|
@@ -262,6 +330,20 @@ function header(rawHeaders, name) {
|
|
|
262
330
|
return rawHeaders.find(([key]) => key === name)?.[1] ?? null;
|
|
263
331
|
}
|
|
264
332
|
|
|
333
|
+
/** Freshness and validator data used by the in-process HTTP cache. */
|
|
334
|
+
function cachePolicy(rawHeaders, now = Date.now()) {
|
|
335
|
+
const control = (header(rawHeaders, "cache-control") ?? "").toLowerCase();
|
|
336
|
+
if (/(?:^|,)\s*no-store(?:\s|,|$)/.test(control)) return { cacheable: false, expires: 0, etag: null };
|
|
337
|
+
const age = control.match(/(?:^|,)\s*max-age\s*=\s*(\d+)/)?.[1];
|
|
338
|
+
const expiresHeader = header(rawHeaders, "expires");
|
|
339
|
+
const expires = age !== undefined
|
|
340
|
+
? now + Number(age) * 1000
|
|
341
|
+
: expiresHeader && Number.isFinite(Date.parse(expiresHeader)) ? Date.parse(expiresHeader)
|
|
342
|
+
: /(?:^|,)\s*immutable(?:\s|,|$)/.test(control) ? now + 365 * 24 * 60 * 60 * 1000 : 0;
|
|
343
|
+
const etag = header(rawHeaders, "etag");
|
|
344
|
+
return { cacheable: expires > now || etag !== null, expires, etag };
|
|
345
|
+
}
|
|
346
|
+
|
|
265
347
|
/** Fetch §4.9 CORS check. */
|
|
266
348
|
function corsAllowed(rawHeaders, origin, withCredentials) {
|
|
267
349
|
const allowOrigin = header(rawHeaders, "access-control-allow-origin");
|
|
@@ -270,3 +352,54 @@ function corsAllowed(rawHeaders, origin, withCredentials) {
|
|
|
270
352
|
if (allowOrigin !== origin) return false;
|
|
271
353
|
return !withCredentials || header(rawHeaders, "access-control-allow-credentials") === "true";
|
|
272
354
|
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Limits the requests in flight to each host (scheme, host and port). A burst
|
|
358
|
+
* of requests that each open a connection slows every transfer, and servers
|
|
359
|
+
* reset some; with a few connections kept alive and reused, a page's
|
|
360
|
+
* resources arrive sooner. Waiting requests go by priority, then in order.
|
|
361
|
+
*/
|
|
362
|
+
class HostThrottle {
|
|
363
|
+
#limit;
|
|
364
|
+
#hosts = new Map();
|
|
365
|
+
|
|
366
|
+
constructor(limit) {
|
|
367
|
+
this.#limit = limit;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Resolves with a function that gives the slot back (once) when the request is done. */
|
|
371
|
+
acquire(url, priority) {
|
|
372
|
+
let key;
|
|
373
|
+
try {
|
|
374
|
+
const target = new URL(url);
|
|
375
|
+
key = `${target.protocol}//${target.host}`;
|
|
376
|
+
} catch {
|
|
377
|
+
key = String(url);
|
|
378
|
+
}
|
|
379
|
+
let host = this.#hosts.get(key);
|
|
380
|
+
if (!host) this.#hosts.set(key, host = { active: 0, waiting: [] });
|
|
381
|
+
const release = this.#releaser(key, host);
|
|
382
|
+
if (host.active < this.#limit) {
|
|
383
|
+
host.active++;
|
|
384
|
+
return Promise.resolve(release);
|
|
385
|
+
}
|
|
386
|
+
return new Promise((resolve) => {
|
|
387
|
+
const entry = { priority, start: () => resolve(release) };
|
|
388
|
+
const index = host.waiting.findIndex((other) => other.priority > priority);
|
|
389
|
+
if (index === -1) host.waiting.push(entry);
|
|
390
|
+
else host.waiting.splice(index, 0, entry);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#releaser(key, host) {
|
|
395
|
+
let released = false;
|
|
396
|
+
return () => {
|
|
397
|
+
if (released) return;
|
|
398
|
+
released = true;
|
|
399
|
+
const next = host.waiting.shift();
|
|
400
|
+
if (next) next.start(); // the slot passes on
|
|
401
|
+
else if (--host.active === 0) this.#hosts.delete(key);
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
@@ -24,7 +24,7 @@ const TRANSPARENT = "rgba(0, 0, 0, 0)";
|
|
|
24
24
|
* @param tree the render tree it was laid out from; without it, visibility,
|
|
25
25
|
* opacity and overflow clipping of ancestors are not applied.
|
|
26
26
|
*/
|
|
27
|
-
export function buildDisplayList(layout, tree = null) {
|
|
27
|
+
export function buildDisplayList(layout, tree = null, resources = null) {
|
|
28
28
|
const items = [];
|
|
29
29
|
const images = [];
|
|
30
30
|
const effects = paintEffects(layout, tree);
|
|
@@ -156,15 +156,18 @@ export function buildDisplayList(layout, tree = null) {
|
|
|
156
156
|
return;
|
|
157
157
|
}
|
|
158
158
|
if (run.type === "image") {
|
|
159
|
+
const currentResource = resources && tree
|
|
160
|
+
? resources.get(tree.nodesById.get(run.nodeId)?.domNode) ?? run.resource
|
|
161
|
+
: run.resource;
|
|
159
162
|
if (run.video || run.audio) {
|
|
160
163
|
const rect = { x: run.x, y, width: run.width, height: run.height };
|
|
161
|
-
images.push(
|
|
164
|
+
images.push(currentResource);
|
|
162
165
|
items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
|
|
163
166
|
return;
|
|
164
167
|
}
|
|
165
|
-
if (!
|
|
168
|
+
if (!currentResource?.image) return;
|
|
166
169
|
const rect = { x: run.x, y, width: run.width, height: run.height };
|
|
167
|
-
images.push(
|
|
170
|
+
images.push(currentResource);
|
|
168
171
|
items.push({
|
|
169
172
|
op: "drawImage", nodeId: run.nodeId, image: images.length - 1, rect, radii: cornerRadii(run.style, rect), bounds: rect,
|
|
170
173
|
});
|
|
@@ -223,17 +226,27 @@ function paintEffects(layout, tree) {
|
|
|
223
226
|
const boxes = new Map();
|
|
224
227
|
for (const box of layout.boxes) if (!box.inline && !boxes.has(box.nodeId)) boxes.set(box.nodeId, box);
|
|
225
228
|
const cache = new Map();
|
|
229
|
+
// Each element's own clips, worked out once for all the items below it.
|
|
230
|
+
const memo = (compute) => {
|
|
231
|
+
const results = new Map();
|
|
232
|
+
return (node) => {
|
|
233
|
+
if (results.has(node)) return results.get(node);
|
|
234
|
+
const result = compute(node);
|
|
235
|
+
results.set(node, result === null ? null : Object.freeze(result));
|
|
236
|
+
return results.get(node);
|
|
237
|
+
};
|
|
238
|
+
};
|
|
226
239
|
|
|
227
240
|
// The clip an element applies to its descendants (and, for clip/clip-path, to itself).
|
|
228
|
-
const overflowClip = (node) => {
|
|
241
|
+
const overflowClip = memo((node) => {
|
|
229
242
|
const style = node.style;
|
|
230
243
|
const box = boxes.get(node.id);
|
|
231
244
|
if (!style || !box || node === tree.root) return null;
|
|
232
245
|
if (style.overflowX !== "clip" && style.overflowY !== "clip") return null;
|
|
233
246
|
const [top, right, bottom, left] = style.borderWidths ?? [0, 0, 0, 0];
|
|
234
247
|
return { x: box.x + left, y: box.y + top, width: box.width - left - right, height: box.height - top - bottom };
|
|
235
|
-
};
|
|
236
|
-
const shapeClip = (node) => {
|
|
248
|
+
});
|
|
249
|
+
const shapeClip = memo((node) => {
|
|
237
250
|
const style = node.style;
|
|
238
251
|
const box = boxes.get(node.id);
|
|
239
252
|
if (!style || !box) return null;
|
|
@@ -253,7 +266,7 @@ function paintEffects(layout, tree) {
|
|
|
253
266
|
rect = intersectRects(rect, { x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
|
|
254
267
|
}
|
|
255
268
|
return rect;
|
|
256
|
-
};
|
|
269
|
+
});
|
|
257
270
|
|
|
258
271
|
return (nodeId, kind) => {
|
|
259
272
|
const key = `${kind}:${nodeId}`;
|
|
@@ -351,7 +364,9 @@ function inflate(rect, amount) {
|
|
|
351
364
|
return { x: rect.x - amount, y: rect.y - amount, width: rect.width + amount * 2, height: rect.height + amount * 2 };
|
|
352
365
|
}
|
|
353
366
|
|
|
367
|
+
/** Freezes an item and what it holds; frozen values (shared clips, style data) are already done. */
|
|
354
368
|
function deepFreeze(value) {
|
|
369
|
+
if (Object.isFrozen(value)) return value;
|
|
355
370
|
for (const child of Object.values(value)) {
|
|
356
371
|
if (child && typeof child === "object") deepFreeze(child);
|
|
357
372
|
}
|
package/lib/render-tree/index.js
CHANGED
|
@@ -30,6 +30,7 @@ const BLOCK_ELEMENTS = new Set([
|
|
|
30
30
|
*/
|
|
31
31
|
export class RenderTreeBuilder {
|
|
32
32
|
#ids = new WeakMap();
|
|
33
|
+
#previous = new WeakMap();
|
|
33
34
|
#pseudoKeys = new WeakMap();
|
|
34
35
|
#nextId = 1;
|
|
35
36
|
#generation = 0;
|
|
@@ -71,12 +72,18 @@ export class RenderTreeBuilder {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
#node(domNode, type, nodesById, properties, idKey = domNode) {
|
|
75
|
+
const previous = this.#previous.get(idKey);
|
|
76
|
+
if (previous && sameNodeProperties(previous, type, properties)) {
|
|
77
|
+
nodesById.set(previous.id, previous);
|
|
78
|
+
return previous;
|
|
79
|
+
}
|
|
74
80
|
const node = Object.freeze({
|
|
75
81
|
id: this.#id(idKey),
|
|
76
82
|
type,
|
|
77
83
|
domNode,
|
|
78
84
|
...properties,
|
|
79
85
|
});
|
|
86
|
+
this.#previous.set(idKey, node);
|
|
80
87
|
nodesById.set(node.id, node);
|
|
81
88
|
return node;
|
|
82
89
|
}
|
|
@@ -242,6 +249,44 @@ export class RenderTreeBuilder {
|
|
|
242
249
|
}
|
|
243
250
|
}
|
|
244
251
|
|
|
252
|
+
function sameNodeProperties(previous, type, properties) {
|
|
253
|
+
if (previous.type !== type || Object.keys(previous).length !== Object.keys(properties).length + 3) return false;
|
|
254
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
255
|
+
const old = previous[key];
|
|
256
|
+
if (old === value) continue;
|
|
257
|
+
if (key !== "children" || old.length !== value.length) return false;
|
|
258
|
+
for (let index = 0; index < old.length; index++) if (old[index] !== value[index]) return false;
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Whether two generations provide identical input to layout. Node IDs are
|
|
265
|
+
* stable for retained DOM nodes, while computed styles and decoded resources
|
|
266
|
+
* are immutable values shared between generations. Callers which separately
|
|
267
|
+
* track whether resource dimensions changed may ignore resource identity.
|
|
268
|
+
*/
|
|
269
|
+
export function sameLayoutInput(left, right, options = {}) {
|
|
270
|
+
if (left === right) return true;
|
|
271
|
+
if (!left || !right || left.id !== right.id || left.type !== right.type
|
|
272
|
+
|| (!options.ignoreStyles && left.style !== right.style) || left.tagName !== right.tagName
|
|
273
|
+
|| left.text !== right.text || left.pseudo !== right.pseudo
|
|
274
|
+
|| (!options.ignoreResources && left.resource !== right.resource)
|
|
275
|
+
|| !sameSvg(left.svg, right.svg)
|
|
276
|
+
|| left.widthAttribute !== right.widthAttribute || left.heightAttribute !== right.heightAttribute
|
|
277
|
+
|| left.children.length !== right.children.length) return false;
|
|
278
|
+
for (let index = 0; index < left.children.length; index++) {
|
|
279
|
+
if (!sameLayoutInput(left.children[index], right.children[index], options)) return false;
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sameSvg(left, right) {
|
|
285
|
+
if (left === right) return true;
|
|
286
|
+
if (!left || !right) return false;
|
|
287
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
288
|
+
}
|
|
289
|
+
|
|
245
290
|
function boxType(style, tagName = null) {
|
|
246
291
|
const display = style?.display;
|
|
247
292
|
if (["flex", "inline-flex", "inline-block", "grid", "inline-grid"].includes(display)) return display;
|
package/lib/renderer/bindings.js
CHANGED
|
@@ -189,8 +189,8 @@ function toNode(wrapper, value) {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
/** The page changed `wrapper`'s node (state the style engine may read: attributes, checked, value). */
|
|
192
|
-
function touch(wrapper) {
|
|
193
|
-
realmOf(wrapper).hooks.touch(NodeBinding.unwrap(wrapper));
|
|
192
|
+
function touch(wrapper, stateFeatures = []) {
|
|
193
|
+
realmOf(wrapper).hooks.touch(NodeBinding.unwrap(wrapper), stateFeatures);
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
/**
|
|
@@ -842,7 +842,7 @@ class ElementBinding extends ParentBinding {
|
|
|
842
842
|
set selectedIndex(value) {
|
|
843
843
|
if (nodeOf(this).tagName !== "SELECT") return;
|
|
844
844
|
nodeOf(this).selectedIndex = Number(value);
|
|
845
|
-
touch(this);
|
|
845
|
+
touch(this, [":state"]);
|
|
846
846
|
}
|
|
847
847
|
|
|
848
848
|
// Overflow inside elements is not scrollable here, so the scroll size is the
|
|
@@ -926,7 +926,7 @@ for (const property of REFLECTED_PROPERTIES) {
|
|
|
926
926
|
node[property] = typeof node[property] === "boolean" ? Boolean(value)
|
|
927
927
|
: typeof node[property] === "number" ? Number(value)
|
|
928
928
|
: String(value);
|
|
929
|
-
touch(this);
|
|
929
|
+
touch(this, [":state"]);
|
|
930
930
|
},
|
|
931
931
|
});
|
|
932
932
|
}
|
package/lib/renderer/host.js
CHANGED
|
@@ -519,7 +519,7 @@ export class RendererHost {
|
|
|
519
519
|
try {
|
|
520
520
|
const response = target.protocol === "data:"
|
|
521
521
|
? await dataResponse(target)
|
|
522
|
-
: await this.#network.subresource(target.href, this.#documentUrl);
|
|
522
|
+
: await this.#network.subresource(target.href, this.#documentUrl, kind);
|
|
523
523
|
if (response.body.byteLength > MAX_RESOURCE_BYTES) return reply({ error: "resource too large" });
|
|
524
524
|
reply({
|
|
525
525
|
result: {
|
|
@@ -28,7 +28,7 @@ const hostStructuredClone = globalThis.structuredClone;
|
|
|
28
28
|
const hostAtob = globalThis.atob;
|
|
29
29
|
const hostBtoa = globalThis.btoa;
|
|
30
30
|
|
|
31
|
-
const CLASSIC_SCRIPT_TYPES = new Set([
|
|
31
|
+
export const CLASSIC_SCRIPT_TYPES = new Set([
|
|
32
32
|
"", "text/javascript", "application/javascript", "text/ecmascript", "application/ecmascript",
|
|
33
33
|
"application/x-javascript", "text/jscript", "text/livescript",
|
|
34
34
|
]);
|
|
@@ -93,7 +93,7 @@ function cssEscape(value) {
|
|
|
93
93
|
* @param {{
|
|
94
94
|
* window: object, document: object, mode: "ses" | "host",
|
|
95
95
|
* hooks: {
|
|
96
|
-
* touch(): void, afterTask(): void,
|
|
96
|
+
* touch(node?: object, stateFeatures?: string[]): void, afterTask(): void,
|
|
97
97
|
* boundsOf(element: object): { left: number, top: number, width: number, height: number } | null,
|
|
98
98
|
* viewport(): { width: number, height: number, scrollX: number, scrollY: number, devicePixelRatio: number },
|
|
99
99
|
* scrollTo(x: number, y: number): void,
|