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
|
@@ -25,14 +25,16 @@
|
|
|
25
25
|
import CanvasKitInit from "../../usr/lib/canvaskit/canvaskit.js";
|
|
26
26
|
import { DESKTOP_USER_AGENT } from "../user-agent.js";
|
|
27
27
|
import { parseHTMLDocument } from "../happy-dom/parser.js";
|
|
28
|
-
import {
|
|
28
|
+
import { LayoutEngine } from "../layout/text-layout.js";
|
|
29
29
|
import { ScrollViewport } from "../layout/scroll-viewport.js";
|
|
30
30
|
import { elementBounds, hitTest, interactiveRegions } from "../input/hit-test.js";
|
|
31
|
-
import { RenderTreeBuilder, renderTreeText } from "../render-tree/index.js";
|
|
32
|
-
import { StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
31
|
+
import { RenderTreeBuilder, renderTreeText, sameLayoutInput } from "../render-tree/index.js";
|
|
32
|
+
import { matchSelectors, StyleEngine, topmostConnected } from "../style/computed-style.js";
|
|
33
|
+
import { compileSelector, matchesCompiled } from "../happy-dom/selector-matcher.js";
|
|
34
|
+
import { elementFeatures, sheetTraits } from "../style/selector-traits.js";
|
|
33
35
|
import { buildDisplayList, visibleItems } from "../paint/display-list.js";
|
|
34
36
|
import { backgroundBytes, cropViewport, encodeImage } from "../paint/frame.js";
|
|
35
|
-
import { createPageRealm } from "./page-realm.js";
|
|
37
|
+
import { CLASSIC_SCRIPT_TYPES, createPageRealm } from "./page-realm.js";
|
|
36
38
|
import { MOBILE_USER_AGENT } from "./flags.js";
|
|
37
39
|
import { formatHint } from "../../vendor/wasmpeg/src/js/formats.js";
|
|
38
40
|
import { floatToS16, splitSamples } from "../audio/pcm.js";
|
|
@@ -47,6 +49,7 @@ const hostClearInterval = globalThis.clearInterval;
|
|
|
47
49
|
|
|
48
50
|
/** How long subresources may take before a preview of the unstyled document is shown. */
|
|
49
51
|
export const PREVIEW_DELAY_MS = 300;
|
|
52
|
+
const MAX_COMPILED_COMPOUNDS = 10_000;
|
|
50
53
|
|
|
51
54
|
// Media controls: the bar's height over video, and where its parts sit.
|
|
52
55
|
const MEDIA_BAR_HEIGHT = 24;
|
|
@@ -166,15 +169,19 @@ export async function createPageRenderer({
|
|
|
166
169
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
167
170
|
const emojiPresentation = /^\p{Emoji_Presentation}/u;
|
|
168
171
|
const renderTreeBuilder = new RenderTreeBuilder();
|
|
172
|
+
const layoutEngine = new LayoutEngine({ measureText, fontMetrics });
|
|
169
173
|
const styleEngine = new StyleEngine();
|
|
170
174
|
// Incremental restyle: what may have changed style since the last
|
|
171
175
|
// computation. DOM mutations come from a MutationObserver; state that is
|
|
172
176
|
// not in the DOM tree (checked, value, focus) is marked by the code that
|
|
173
177
|
// changes it. `full` asks for a complete restyle.
|
|
174
|
-
const styleChanges = { full: true, roots: new Set(), records: [], observer: null, activeElement: null };
|
|
178
|
+
const styleChanges = { full: true, roots: new Set(), rootFeatures: new Map(), records: [], observer: null, activeElement: null };
|
|
175
179
|
let selectorTraits = { sibling: false, has: null, sheets: null };
|
|
176
180
|
let lastStyleParts = "";
|
|
181
|
+
let laidOutSheets = null;
|
|
177
182
|
const sheetTraitsCache = new Map();
|
|
183
|
+
// Invalidation compounds compiled for matchesCompound() and matchSelectors().
|
|
184
|
+
const compiledCompounds = new Map();
|
|
178
185
|
let lastStyleReason = "";
|
|
179
186
|
let hasState = null;
|
|
180
187
|
let hasStateStale = false;
|
|
@@ -199,6 +206,8 @@ export async function createPageRenderer({
|
|
|
199
206
|
hintsVisible: false,
|
|
200
207
|
styleSheets: [],
|
|
201
208
|
linkSheets: null,
|
|
209
|
+
// The document's scripts fetched ahead of their turn (see preloadScripts), by URL.
|
|
210
|
+
preloadedScripts: new Map(),
|
|
202
211
|
backgroundColor: "rgba(255, 255, 255, 1)",
|
|
203
212
|
resources: new WeakMap(),
|
|
204
213
|
decodedImages: [],
|
|
@@ -209,6 +218,7 @@ export async function createPageRenderer({
|
|
|
209
218
|
// "off" until the renderer process has set up its sandbox.
|
|
210
219
|
scripting: "off",
|
|
211
220
|
dirty: false,
|
|
221
|
+
resourceLayoutDirty: false,
|
|
212
222
|
userAgent: mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT,
|
|
213
223
|
// document.cookie as last reported by the controller (non-HttpOnly only).
|
|
214
224
|
cookie: "",
|
|
@@ -845,19 +855,24 @@ export async function createPageRenderer({
|
|
|
845
855
|
|
|
846
856
|
/** `linkSheets` (link element -> { url, text }) makes the sheet list follow the DOM; without it the given sheets are fixed. */
|
|
847
857
|
/** Marks a node whose own state changed (its subtree may restyle); null asks for a full restyle. */
|
|
848
|
-
function markStyleDirty(node) {
|
|
858
|
+
function markStyleDirty(node, features = [":state"]) {
|
|
849
859
|
// Documents and fragments carry no state selectors read; their children's
|
|
850
860
|
// changes arrive as mutation records.
|
|
851
861
|
if (node?.nodeType === 9 || node?.nodeType === 11) return;
|
|
852
862
|
const element = node?.nodeType === 1 ? node : node?.parentElement;
|
|
853
863
|
if (!element) styleChanges.full ||= node ? `touched ${node.nodeName}` : "unknown change";
|
|
854
|
-
else
|
|
864
|
+
else {
|
|
865
|
+
styleChanges.roots.add(element);
|
|
866
|
+
const previous = styleChanges.rootFeatures.get(element);
|
|
867
|
+
styleChanges.rootFeatures.set(element, previous ? [...new Set([...previous, ...features])] : features);
|
|
868
|
+
}
|
|
855
869
|
}
|
|
856
870
|
|
|
857
871
|
function observeStyleChanges(document) {
|
|
858
872
|
styleChanges.observer?.disconnect();
|
|
859
873
|
styleChanges.records = [];
|
|
860
874
|
styleChanges.roots = new Set();
|
|
875
|
+
styleChanges.rootFeatures = new Map();
|
|
861
876
|
styleChanges.full = "new document";
|
|
862
877
|
styleChanges.activeElement = null;
|
|
863
878
|
const observer = new page.window.MutationObserver((records) => { styleChanges.records.push(...records); });
|
|
@@ -882,6 +897,7 @@ export async function createPageRenderer({
|
|
|
882
897
|
sheets, sibling: any("sibling"), hasSiblings: any("hasSiblings"),
|
|
883
898
|
hasCompounds: [...union("hasCompounds")], structuralCompounds: [...union("structuralCompounds")],
|
|
884
899
|
descendantFeatures: union("descendantFeatures"), siblingFeatures: union("siblingFeatures"),
|
|
900
|
+
descendantStateCompounds: parts.flatMap((part) => part.descendantStateCompounds),
|
|
885
901
|
siblingTargetCompounds: [...union("siblingTargetCompounds")],
|
|
886
902
|
forward: any("forward"), firstChild: any("firstChild"), backward: any("backward"), lastChild: any("lastChild"),
|
|
887
903
|
defined: any("defined"), target: any("target"),
|
|
@@ -898,70 +914,7 @@ export async function createPageRenderer({
|
|
|
898
914
|
function sheetTraitsFor(sheet) {
|
|
899
915
|
let traits = sheetTraitsCache.get(sheet);
|
|
900
916
|
if (traits) return traits;
|
|
901
|
-
|
|
902
|
-
const plain = text.replace(/\[[^\]]*\]/g, "[]").replace(/\{[^}]*\}/g, "{}");
|
|
903
|
-
const sibling = /[^\s~|^$*=\[]\s*[+~]\s*[^\s=]/.test(plain);
|
|
904
|
-
const hasCompounds = new Set();
|
|
905
|
-
let hasSiblings = false;
|
|
906
|
-
const selectorsOnly = text.replace(/\{[^}]*\}/g, "{}");
|
|
907
|
-
for (const block of selectorsOnly.matchAll(/[^{}]*:has\([^{]*\{/g)) {
|
|
908
|
-
for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
|
|
909
|
-
for (const compound of compoundsOf(selector)) {
|
|
910
|
-
if (!compound.includes(":has(")) continue;
|
|
911
|
-
hasCompounds.add(compound.replace(/::[\w-]+(\([^)]*\))?$/, ""));
|
|
912
|
-
if (/:has\(\s*[+~]/.test(compound)) hasSiblings = true;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
// Structural pseudo-classes: which siblings a child list change can
|
|
917
|
-
// affect, and the compounds where they are not the subject (".a:last-child .b"),
|
|
918
|
-
// whose matches decide whether a sibling's subtree must restyle too.
|
|
919
|
-
const structural = /:(?:nth-child|nth-last-child|first-child|last-child|only-child|nth-of-type|nth-last-of-type|first-of-type|last-of-type|only-of-type)\b/;
|
|
920
|
-
const structuralCompounds = new Set();
|
|
921
|
-
for (const block of selectorsOnly.matchAll(/[^{}]*\{/g)) {
|
|
922
|
-
if (!structural.test(block[0])) continue;
|
|
923
|
-
for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
|
|
924
|
-
const compounds = compoundsOf(selector);
|
|
925
|
-
for (const compound of compounds.slice(0, -1)) if (structural.test(compound)) structuralCompounds.add(compound);
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
// Invalidation features (class ".x", id "#x", attribute "[x", tag "t:x",
|
|
929
|
-
// state ":state", anything "*") of compounds that are not a selector's
|
|
930
|
-
// subject: left of a descendant/child combinator, a change to them can
|
|
931
|
-
// restyle the element's subtree; left of + / ~, its later siblings.
|
|
932
|
-
const descendantFeatures = new Set();
|
|
933
|
-
const siblingFeatures = new Set();
|
|
934
|
-
// Selectors ending in a compound right of + / ~ that is not the subject
|
|
935
|
-
// (".a + .b" of ".a + .b .c"): a later sibling's subtree restyles only
|
|
936
|
-
// when its match of one of them changes.
|
|
937
|
-
const siblingTargetCompounds = new Set();
|
|
938
|
-
for (const block of selectorsOnly.matchAll(/[^{}@]*\{/g)) {
|
|
939
|
-
for (const selector of splitTopLevelText(block[0].slice(0, -1), ",")) {
|
|
940
|
-
const parts = complexParts(selector);
|
|
941
|
-
parts.forEach(({ compound, combinator }, index) => {
|
|
942
|
-
const nested = /\([^)]*[\s>+~][^)]*\)/.test(compound); // :is(.a .b) hides combinators
|
|
943
|
-
const previous = parts[index - 1]?.combinator;
|
|
944
|
-
if ((previous === "+" || previous === "~") && index < parts.length - 1) {
|
|
945
|
-
// With what is left of it ("li:first-child + li"): the relation to
|
|
946
|
-
// the earlier sibling can end while the compound still matches.
|
|
947
|
-
const left = parts.slice(0, index).map((part) => `${part.compound} ${part.combinator === " " ? "" : part.combinator + " "}`).join("");
|
|
948
|
-
siblingTargetCompounds.add(left + compound);
|
|
949
|
-
}
|
|
950
|
-
if (index === parts.length - 1 && !nested) return;
|
|
951
|
-
const features = compoundFeatures(compound);
|
|
952
|
-
if (nested || combinator === " " || combinator === ">") for (const feature of features) descendantFeatures.add(feature);
|
|
953
|
-
if (nested || combinator === "+" || combinator === "~") for (const feature of features) siblingFeatures.add(feature);
|
|
954
|
-
});
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
traits = {
|
|
958
|
-
sibling, hasCompounds, hasSiblings, structuralCompounds, descendantFeatures, siblingFeatures, siblingTargetCompounds,
|
|
959
|
-
forward: /:(?:nth-child|nth-of-type|first-of-type|only-of-type)\b/.test(selectorsOnly),
|
|
960
|
-
firstChild: /:(?:first-child|only-child)\b/.test(selectorsOnly),
|
|
961
|
-
backward: /:(?:nth-last-child|nth-last-of-type|last-of-type|only-of-type)\b/.test(selectorsOnly),
|
|
962
|
-
lastChild: /:(?:last-child|only-child)\b/.test(selectorsOnly),
|
|
963
|
-
defined: text.includes(":defined"), target: /:target\b/.test(text),
|
|
964
|
-
};
|
|
917
|
+
traits = sheetTraits(sheet);
|
|
965
918
|
sheetTraitsCache.set(sheet, traits);
|
|
966
919
|
return traits;
|
|
967
920
|
}
|
|
@@ -973,11 +926,14 @@ export async function createPageRenderer({
|
|
|
973
926
|
*/
|
|
974
927
|
function recordHasState(traits) {
|
|
975
928
|
const reuse = hasState !== null;
|
|
929
|
+
if (compiledCompounds.size >= MAX_COMPILED_COMPOUNDS) compiledCompounds.clear();
|
|
976
930
|
const record = (compounds, previous) => {
|
|
931
|
+
const missing = compounds.filter((compound) => !(reuse && previous?.has(compound)));
|
|
932
|
+
const matched = missing.length ? matchSelectors(page.document, missing, compiledCompounds) : null;
|
|
977
933
|
const state = new Map();
|
|
978
934
|
for (const compound of compounds) {
|
|
979
|
-
if (reuse && previous?.has(compound))
|
|
980
|
-
|
|
935
|
+
if (reuse && previous?.has(compound)) state.set(compound, previous.get(compound));
|
|
936
|
+
else if (matched.has(compound)) state.set(compound, matched.get(compound)); // invalid ones never match
|
|
981
937
|
}
|
|
982
938
|
return state;
|
|
983
939
|
};
|
|
@@ -988,13 +944,12 @@ export async function createPageRenderer({
|
|
|
988
944
|
|
|
989
945
|
/** Brings the recorded matches of an inserted subtree up to date (it may have been in the document before). */
|
|
990
946
|
function recordInserted(root) {
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
} catch { continue; }
|
|
947
|
+
const states = [hasState, structuralState, siblingState].filter(Boolean);
|
|
948
|
+
const now = matchSelectors(root, states.flatMap((state) => [...state.keys()]), compiledCompounds);
|
|
949
|
+
for (const state of states) {
|
|
950
|
+
for (const [compound, matched] of state) {
|
|
951
|
+
const inside = now.get(compound);
|
|
952
|
+
if (!inside) continue;
|
|
998
953
|
for (const element of [root, ...root.getElementsByTagName("*")]) {
|
|
999
954
|
if (inside.has(element)) matched.add(element); else matched.delete(element);
|
|
1000
955
|
}
|
|
@@ -1003,12 +958,24 @@ export async function createPageRenderer({
|
|
|
1003
958
|
}
|
|
1004
959
|
|
|
1005
960
|
|
|
961
|
+
/** element.matches(compound), with the compound compiled once (see matchSelectors). */
|
|
962
|
+
function matchesCompound(element, compound) {
|
|
963
|
+
let compiled = compiledCompounds.get(compound);
|
|
964
|
+
if (compiled === undefined) {
|
|
965
|
+
compiled = compileSelector(element, compound);
|
|
966
|
+
if (compiled === undefined) return element.matches(compound);
|
|
967
|
+
if (compiledCompounds.size >= MAX_COMPILED_COMPOUNDS) compiledCompounds.clear();
|
|
968
|
+
compiledCompounds.set(compound, compiled);
|
|
969
|
+
}
|
|
970
|
+
return compiled !== null && matchesCompiled(element, compiled);
|
|
971
|
+
}
|
|
972
|
+
|
|
1006
973
|
/** Re-checks `element` against recorded compounds; whether any match changed (the state is updated). */
|
|
1007
974
|
function matchesChanged(state, element) {
|
|
1008
975
|
let changed = false;
|
|
1009
976
|
for (const [compound, matched] of state ?? []) {
|
|
1010
977
|
let now = false;
|
|
1011
|
-
try { now = element
|
|
978
|
+
try { now = matchesCompound(element, compound); } catch {}
|
|
1012
979
|
if (now === matched.has(element)) continue;
|
|
1013
980
|
if (now) matched.add(element); else matched.delete(element);
|
|
1014
981
|
changed = true;
|
|
@@ -1034,7 +1001,7 @@ export async function createPageRenderer({
|
|
|
1034
1001
|
checked.add(candidate);
|
|
1035
1002
|
for (const [compound, matched] of hasState) {
|
|
1036
1003
|
let now = false;
|
|
1037
|
-
try { now = candidate
|
|
1004
|
+
try { now = matchesCompound(candidate, compound); } catch {}
|
|
1038
1005
|
if (now === matched.has(candidate)) continue;
|
|
1039
1006
|
if (now) matched.add(candidate); else matched.delete(candidate);
|
|
1040
1007
|
add(candidate);
|
|
@@ -1055,6 +1022,10 @@ export async function createPageRenderer({
|
|
|
1055
1022
|
const records = styleChanges.records;
|
|
1056
1023
|
if (styleChanges.observer) records.push(...styleChanges.observer.takeRecords());
|
|
1057
1024
|
const roots = styleChanges.roots;
|
|
1025
|
+
// Explicit roots mean that state-dependent selectors may need restyling;
|
|
1026
|
+
// they do not by themselves change render-tree content. DOM mutation
|
|
1027
|
+
// records below identify structural/content changes separately.
|
|
1028
|
+
let treeDirty = false;
|
|
1058
1029
|
let full = styleChanges.full;
|
|
1059
1030
|
const active = page.document.activeElement;
|
|
1060
1031
|
if (active !== styleChanges.activeElement) {
|
|
@@ -1081,11 +1052,22 @@ export async function createPageRenderer({
|
|
|
1081
1052
|
if (matchesChanged(siblingState, element)) roots.add(element);
|
|
1082
1053
|
};
|
|
1083
1054
|
const later = (element) => { for (let next = element?.nextElementSibling; next; next = next.nextElementSibling) laterSibling(next); };
|
|
1055
|
+
const featureMatches = (used, changedFeatures) => changedFeatures.some((feature) => feature === ":state"
|
|
1056
|
+
? [...used].some((candidate) => candidate.startsWith(":"))
|
|
1057
|
+
: used.has(feature));
|
|
1058
|
+
const stateAffectsDescendants = (element, changedFeatures) => {
|
|
1059
|
+
const states = changedFeatures.filter((feature) => feature.startsWith(":"));
|
|
1060
|
+
if (!states.length) return false;
|
|
1061
|
+
const available = new Set(elementFeatures(element));
|
|
1062
|
+
return traits.descendantStateCompounds.some((entry) => (states.includes(":state") || entry.states.some((state) => states.includes(state)))
|
|
1063
|
+
&& entry.required.every((feature) => available.has(feature)));
|
|
1064
|
+
};
|
|
1084
1065
|
/** An element whose features changed: itself, and wider where non-subject compounds use them. */
|
|
1085
1066
|
const changed = (element, features) => {
|
|
1086
1067
|
self.add(element);
|
|
1087
|
-
|
|
1088
|
-
if (traits.
|
|
1068
|
+
const ordinary = features.filter((feature) => !feature.startsWith(":"));
|
|
1069
|
+
if (featureMatches(traits.descendantFeatures, ordinary) || stateAffectsDescendants(element, features)) roots.add(element);
|
|
1070
|
+
if (traits.siblingFeatures.has("*") || featureMatches(traits.siblingFeatures, features)) later(element);
|
|
1089
1071
|
hasChanges(traits, element, add, hasChecked);
|
|
1090
1072
|
};
|
|
1091
1073
|
const touchSibling = (element) => {
|
|
@@ -1099,7 +1081,7 @@ export async function createPageRenderer({
|
|
|
1099
1081
|
// the records also describe; both start as a change of the element's state.
|
|
1100
1082
|
for (const element of [...roots]) {
|
|
1101
1083
|
roots.delete(element);
|
|
1102
|
-
if (!full) changed(element, [":state"]);
|
|
1084
|
+
if (!full) changed(element, styleChanges.rootFeatures.get(element) ?? [":state"]);
|
|
1103
1085
|
}
|
|
1104
1086
|
for (const record of records) {
|
|
1105
1087
|
if (full) break;
|
|
@@ -1107,6 +1089,10 @@ export async function createPageRenderer({
|
|
|
1107
1089
|
if (record.type === "attributes") {
|
|
1108
1090
|
if (target.nodeType !== 1) continue;
|
|
1109
1091
|
const name = record.attributeName;
|
|
1092
|
+
// data/ARIA/title changes have no direct render-tree representation;
|
|
1093
|
+
// selectors that observe them are handled by the style diff below.
|
|
1094
|
+
// Other attributes may alter controls, replaced elements or visibility.
|
|
1095
|
+
if (!name.startsWith("data-") && !name.startsWith("aria-") && name !== "title") treeDirty = true;
|
|
1110
1096
|
const features = ["[" + name];
|
|
1111
1097
|
if (name === "class") {
|
|
1112
1098
|
const before = new Set(String(record.oldValue ?? "").split(/\s+/).filter(Boolean));
|
|
@@ -1120,6 +1106,7 @@ export async function createPageRenderer({
|
|
|
1120
1106
|
}
|
|
1121
1107
|
changed(target, features);
|
|
1122
1108
|
} else if (record.type === "childList") {
|
|
1109
|
+
treeDirty = true;
|
|
1123
1110
|
if (target.nodeType !== 1) { full = `children of ${target.nodeName}`; break; }
|
|
1124
1111
|
// Inserted elements restyle; the parent itself may change (:empty);
|
|
1125
1112
|
// siblings around the change point may match structural
|
|
@@ -1150,6 +1137,7 @@ export async function createPageRenderer({
|
|
|
1150
1137
|
}
|
|
1151
1138
|
}
|
|
1152
1139
|
} else if (record.type === "characterData") {
|
|
1140
|
+
treeDirty = true;
|
|
1153
1141
|
// Only :empty and :has() can see text: the parent itself.
|
|
1154
1142
|
const parent = target.parentElement;
|
|
1155
1143
|
if (parent) {
|
|
@@ -1161,10 +1149,11 @@ export async function createPageRenderer({
|
|
|
1161
1149
|
if (!full) for (const node of topmostConnected(inserted, page.document)) recordInserted(node);
|
|
1162
1150
|
styleChanges.records = [];
|
|
1163
1151
|
styleChanges.roots = new Set();
|
|
1152
|
+
styleChanges.rootFeatures = new Map();
|
|
1164
1153
|
styleChanges.full = false;
|
|
1165
1154
|
lastStyleReason = full || `${roots.size} root(s), ${self.size} self`;
|
|
1166
1155
|
if (full) hasState = null; // recorded again after the full restyle
|
|
1167
|
-
return full ? null : { roots, self };
|
|
1156
|
+
return full ? null : { roots, self, treeDirty };
|
|
1168
1157
|
}
|
|
1169
1158
|
|
|
1170
1159
|
function replaceParsedDocument(parsed, styleSheets = [], imageResources = emptyImageResources(), linkSheets = null) {
|
|
@@ -1197,10 +1186,19 @@ export async function createPageRenderer({
|
|
|
1197
1186
|
/** Style, render tree, layout and display list of the current document. */
|
|
1198
1187
|
function styleAndLayout() {
|
|
1199
1188
|
const started = hostPerformance.now();
|
|
1189
|
+
const ms = (from, to) => Math.round(to - from);
|
|
1200
1190
|
const sheets = currentStyleSheets();
|
|
1201
1191
|
const environment = { width: page.width, height: page.height, mobile };
|
|
1202
1192
|
const changes = takeStyleChanges(sheets); // drained even when unused, so records do not pile up
|
|
1203
1193
|
const taken = hostPerformance.now();
|
|
1194
|
+
// A rendering opportunity can be scheduled by lifecycle work which did
|
|
1195
|
+
// not actually change DOM, state, resources, or styles. Keep the retained
|
|
1196
|
+
// layout/display list in that case instead of laying the whole page out.
|
|
1197
|
+
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1198
|
+
&& changes.roots.size === 0 && changes.self.size === 0) {
|
|
1199
|
+
lastStyleParts = `changes ${Math.round(taken - started)}, no invalidation`;
|
|
1200
|
+
return { style: Math.round(taken - started), layout: 0, skipped: true };
|
|
1201
|
+
}
|
|
1204
1202
|
styleEngine.compute(page.document, sheets, environment, fullStyle ? null : changes?.roots ?? null, changes?.self ?? null);
|
|
1205
1203
|
const computed = hostPerformance.now();
|
|
1206
1204
|
if (!hasState || hasStateStale) {
|
|
@@ -1208,17 +1206,40 @@ export async function createPageRenderer({
|
|
|
1208
1206
|
hasStateStale = false;
|
|
1209
1207
|
}
|
|
1210
1208
|
const recorded = hostPerformance.now();
|
|
1209
|
+
// Attribute mutations used by application bookkeeping often match no
|
|
1210
|
+
// different rule and do not alter render-tree content. Once style has
|
|
1211
|
+
// proved that no element changed, retain layout and paint unchanged.
|
|
1212
|
+
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets && changes
|
|
1213
|
+
&& !changes.treeDirty && styleEngine.incremental && styleEngine.restyled === 0) {
|
|
1214
|
+
lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, no visual change`;
|
|
1215
|
+
return { style: Math.round(recorded - started), layout: 0, skipped: true };
|
|
1216
|
+
}
|
|
1211
1217
|
if (verifyStyle && styleEngine.incremental) verifyIncrementalStyle(sheets, environment);
|
|
1212
1218
|
const verified = hostPerformance.now();
|
|
1213
1219
|
page.backgroundColor = documentBackground(page.document, styleEngine);
|
|
1214
|
-
|
|
1220
|
+
const nextRenderTree = renderTreeBuilder.build(page.document, styleEngine, page.resources);
|
|
1215
1221
|
const styled = hostPerformance.now();
|
|
1216
|
-
const ms = (from, to) => Math.round(to - from);
|
|
1217
1222
|
lastStyleParts = `changes ${ms(started, taken)}, compute ${ms(taken, computed)}, record ${ms(computed, recorded)}, tree ${ms(verified, styled)}`;
|
|
1218
|
-
|
|
1223
|
+
// MutationObserver also reports changes under non-rendered nodes such as
|
|
1224
|
+
// script and metadata elements. Compare the actual immutable layout input
|
|
1225
|
+
// after rebuilding it, so those changes do not trigger a full reflow.
|
|
1226
|
+
if (page.renderTree && !page.resourceLayoutDirty && sheets === laidOutSheets
|
|
1227
|
+
&& sameLayoutInput(page.renderTree.root, nextRenderTree.root, {
|
|
1228
|
+
ignoreResources: true,
|
|
1229
|
+
// A zero-element incremental pass retained every computed style.
|
|
1230
|
+
// Control placeholders may still wrap that style in a fresh object.
|
|
1231
|
+
ignoreStyles: styleEngine.incremental && styleEngine.restyled === 0,
|
|
1232
|
+
})) {
|
|
1233
|
+
lastStyleParts += ", unchanged layout input";
|
|
1234
|
+
return { style: Math.round(styled - started), layout: 0, skipped: true };
|
|
1235
|
+
}
|
|
1236
|
+
page.renderTree = nextRenderTree;
|
|
1237
|
+
page.layout = layoutEngine.layout(page.renderTree, {
|
|
1219
1238
|
measureText, fontMetrics, width: page.width, viewportHeight: page.height, x: 0, y: 0,
|
|
1220
1239
|
});
|
|
1221
|
-
page.displayList = buildDisplayList(page.layout, page.renderTree);
|
|
1240
|
+
page.displayList = buildDisplayList(page.layout, page.renderTree, page.resources);
|
|
1241
|
+
page.resourceLayoutDirty = false;
|
|
1242
|
+
laidOutSheets = sheets;
|
|
1222
1243
|
viewport.setContentSize(page.layout.width, page.layout.height);
|
|
1223
1244
|
return { style: Math.round(styled - started), layout: Math.round(hostPerformance.now() - styled) };
|
|
1224
1245
|
}
|
|
@@ -1247,9 +1268,9 @@ export async function createPageRenderer({
|
|
|
1247
1268
|
function rebuildRenderTree(reason = "update") {
|
|
1248
1269
|
const started = hostPerformance.now();
|
|
1249
1270
|
page.dirty = false;
|
|
1250
|
-
page.needsPaint = true;
|
|
1251
1271
|
page.title = page.document?.title ?? page.title;
|
|
1252
1272
|
const timing = styleAndLayout();
|
|
1273
|
+
if (!timing.skipped) page.needsPaint = true;
|
|
1253
1274
|
lastRebuildMs = hostPerformance.now() - started;
|
|
1254
1275
|
trace?.(`${page.url}: restyle and relayout (${reason}) ${Math.round(lastRebuildMs)}ms`
|
|
1255
1276
|
+ ` (style ${timing.style}ms for ${styleEngine.incremental ? styleEngine.restyled : "all"} elements: ${lastStyleReason}; ${lastStyleParts}, layout ${timing.layout}ms)`);
|
|
@@ -1329,6 +1350,7 @@ export async function createPageRenderer({
|
|
|
1329
1350
|
const linkSheets = isHtml ? new WeakMap() : null;
|
|
1330
1351
|
const styleSheets = isHtml ? loadStyleSheets(parsed.document, url, linkSheets) : Promise.resolve([]);
|
|
1331
1352
|
const images = isHtml ? loadImageResources(parsed.document, url) : Promise.resolve(emptyImageResources());
|
|
1353
|
+
page.preloadedScripts = isHtml && page.scripting !== "off" ? preloadScripts(parsed.document, url) : new Map();
|
|
1332
1354
|
let imagesReady = null;
|
|
1333
1355
|
images.then((loaded) => { imagesReady = loaded; });
|
|
1334
1356
|
let timer;
|
|
@@ -1387,6 +1409,31 @@ export async function createPageRenderer({
|
|
|
1387
1409
|
}
|
|
1388
1410
|
}
|
|
1389
1411
|
|
|
1412
|
+
/**
|
|
1413
|
+
* Like a browser's preload scanner: starts fetching the document's external
|
|
1414
|
+
* scripts and declared module preloads (<link rel=modulepreload>) as soon
|
|
1415
|
+
* as it is parsed, while its stylesheets load and it is styled, instead of
|
|
1416
|
+
* when the scripts' turn comes and a module graph is discovered level by
|
|
1417
|
+
* level. Only the bytes are fetched early; fetchScript() takes them.
|
|
1418
|
+
*/
|
|
1419
|
+
function preloadScripts(document, documentUrl) {
|
|
1420
|
+
const preloaded = new Map();
|
|
1421
|
+
const preload = (href) => {
|
|
1422
|
+
let target;
|
|
1423
|
+
try { target = new URL(href, documentUrl); } catch { return; }
|
|
1424
|
+
if (!["http:", "https:"].includes(target.protocol) || preloaded.has(target.href)) return;
|
|
1425
|
+
const response = fetchResource(target.href, "script");
|
|
1426
|
+
response.catch(() => {}); // reported when the script runs
|
|
1427
|
+
preloaded.set(target.href, response);
|
|
1428
|
+
};
|
|
1429
|
+
for (const script of document.querySelectorAll("script[src]")) {
|
|
1430
|
+
const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
|
|
1431
|
+
if (type === "module" || (CLASSIC_SCRIPT_TYPES.has(type) && !script.hasAttribute("nomodule"))) preload(script.getAttribute("src"));
|
|
1432
|
+
}
|
|
1433
|
+
for (const link of document.querySelectorAll("link[rel~='modulepreload'][href]")) preload(link.getAttribute("href"));
|
|
1434
|
+
return preloaded;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1390
1437
|
/** Frees what a superseded load fetched. */
|
|
1391
1438
|
async function discard(images, parsed = null) {
|
|
1392
1439
|
parsed?.window?.happyDOM?.abort?.();
|
|
@@ -1420,6 +1467,7 @@ export async function createPageRenderer({
|
|
|
1420
1467
|
page.decodedImages.push(resource.image);
|
|
1421
1468
|
}
|
|
1422
1469
|
page.dirty = true;
|
|
1470
|
+
page.resourceLayoutDirty = true;
|
|
1423
1471
|
scheduleFrame();
|
|
1424
1472
|
}, () => {});
|
|
1425
1473
|
}
|
|
@@ -1514,12 +1562,14 @@ export async function createPageRenderer({
|
|
|
1514
1562
|
return {
|
|
1515
1563
|
definedChanged(name) {
|
|
1516
1564
|
// An upgrade changes :defined for that tag's elements only.
|
|
1517
|
-
if (selectorTraits.defined)
|
|
1565
|
+
if (selectorTraits.defined) {
|
|
1566
|
+
for (const element of page.document.getElementsByTagName(String(name))) markStyleDirty(element, [":defined"]);
|
|
1567
|
+
}
|
|
1518
1568
|
page.dirty = true;
|
|
1519
1569
|
scheduleFrame();
|
|
1520
1570
|
},
|
|
1521
|
-
touch(node = null) {
|
|
1522
|
-
markStyleDirty(node);
|
|
1571
|
+
touch(node = null, stateFeatures = []) {
|
|
1572
|
+
if (stateFeatures.length) markStyleDirty(node, stateFeatures);
|
|
1523
1573
|
page.dirty = true;
|
|
1524
1574
|
scheduleFrame();
|
|
1525
1575
|
},
|
|
@@ -1575,7 +1625,9 @@ export async function createPageRenderer({
|
|
|
1575
1625
|
if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
|
|
1576
1626
|
return response.text();
|
|
1577
1627
|
}
|
|
1578
|
-
const
|
|
1628
|
+
const preloaded = page.preloadedScripts.get(url);
|
|
1629
|
+
if (preloaded) page.preloadedScripts.delete(url);
|
|
1630
|
+
const response = await (preloaded ?? fetchResource(url, "script"));
|
|
1579
1631
|
if (!response.ok) throw new Error(`script ${url} failed with HTTP ${response.status}`);
|
|
1580
1632
|
return new TextDecoder().decode(response.body);
|
|
1581
1633
|
},
|
|
@@ -2604,7 +2656,25 @@ export async function createPageRenderer({
|
|
|
2604
2656
|
}
|
|
2605
2657
|
page.resources.set(element, Object.freeze({ width, height, image, url: response.url }));
|
|
2606
2658
|
page.decodedImages.push(image);
|
|
2607
|
-
page.
|
|
2659
|
+
const node = [...page.renderTree.nodesById.values()].find((candidate) => candidate.domNode === element);
|
|
2660
|
+
const run = page.layout.fragments.flatMap((fragment) => fragment.runs)
|
|
2661
|
+
.find((candidate) => candidate.type === "image" && candidate.nodeId === node?.id);
|
|
2662
|
+
const cssWidth = node?.style?.width;
|
|
2663
|
+
const cssHeight = node?.style?.height;
|
|
2664
|
+
const specifiedWidth = typeof cssWidth === "number" ? cssWidth : node?.widthAttribute;
|
|
2665
|
+
const specifiedHeight = typeof cssHeight === "number" ? cssHeight : node?.heightAttribute;
|
|
2666
|
+
// When both used dimensions were already fixed, decoded pixels only
|
|
2667
|
+
// change paint. The retained layout remains authoritative and the
|
|
2668
|
+
// display list resolves the current resource by DOM node.
|
|
2669
|
+
const paintOnly = run && Number.isFinite(specifiedWidth) && Number.isFinite(specifiedHeight)
|
|
2670
|
+
&& Math.abs(run.width - specifiedWidth) < 0.01 && Math.abs(run.height - specifiedHeight) < 0.01;
|
|
2671
|
+
if (paintOnly) {
|
|
2672
|
+
page.displayList = buildDisplayList(page.layout, page.renderTree, page.resources);
|
|
2673
|
+
page.needsPaint = true;
|
|
2674
|
+
} else {
|
|
2675
|
+
page.dirty = true;
|
|
2676
|
+
page.resourceLayoutDirty = true;
|
|
2677
|
+
}
|
|
2608
2678
|
refresh();
|
|
2609
2679
|
}).catch(() => {
|
|
2610
2680
|
// A failed image retains its fallback replaced-element dimensions.
|
|
@@ -2614,114 +2684,6 @@ export async function createPageRenderer({
|
|
|
2614
2684
|
}
|
|
2615
2685
|
}
|
|
2616
2686
|
|
|
2617
|
-
/** Splits at top-level `separator` (outside (), [] and quotes). */
|
|
2618
|
-
function splitTopLevelText(text, separator) {
|
|
2619
|
-
const parts = [];
|
|
2620
|
-
let depth = 0;
|
|
2621
|
-
let quote = "";
|
|
2622
|
-
let start = 0;
|
|
2623
|
-
for (let index = 0; index < text.length; index++) {
|
|
2624
|
-
const character = text[index];
|
|
2625
|
-
if (quote) { if (character === "\\") index++; else if (character === quote) quote = ""; continue; }
|
|
2626
|
-
if (character === '"' || character === "'") quote = character;
|
|
2627
|
-
else if (character === "\\") index++;
|
|
2628
|
-
else if (character === "(" || character === "[") depth++;
|
|
2629
|
-
else if (character === ")" || character === "]") depth--;
|
|
2630
|
-
else if (character === separator && depth === 0) { parts.push(text.slice(start, index).trim()); start = index + 1; }
|
|
2631
|
-
}
|
|
2632
|
-
parts.push(text.slice(start).trim());
|
|
2633
|
-
return parts.filter(Boolean);
|
|
2634
|
-
}
|
|
2635
|
-
|
|
2636
|
-
const STATE_PSEUDO = /:(?:checked|indeterminate|default|disabled|enabled|placeholder-shown|valid|invalid|in-range|out-of-range|required|optional|read-only|read-write|user-valid|user-invalid|autofill|open|popover-open|modal|target|focus|focus-visible|focus-within|hover|active|visited|link|any-link|defined|fullscreen)\b/;
|
|
2637
|
-
|
|
2638
|
-
/** CSS escapes in an identifier (".\\:popover-open", "\\31 a") resolved. */
|
|
2639
|
-
function cssUnescape(text) {
|
|
2640
|
-
return text.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16))).replace(/\\(.)/g, "$1");
|
|
2641
|
-
}
|
|
2642
|
-
|
|
2643
|
-
/** Invalidation features a compound selector mentions (see selectorTraitsFor). */
|
|
2644
|
-
function compoundFeatures(compound) {
|
|
2645
|
-
const features = [];
|
|
2646
|
-
const plain = compound.replace(/\[[^\]]*\]/g, (attribute) => {
|
|
2647
|
-
const name = /^\[\s*([\w-]+)/.exec(attribute)?.[1];
|
|
2648
|
-
if (name) features.push("[" + name.toLowerCase());
|
|
2649
|
-
return "";
|
|
2650
|
-
});
|
|
2651
|
-
for (const match of plain.matchAll(/([.#])((?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w\u00a0-\uffff-])+)/g)) features.push(match[1] + cssUnescape(match[2]));
|
|
2652
|
-
const tag = /^(?:[a-zA-Z][\w-]*)/.exec(plain)?.[0];
|
|
2653
|
-
if (tag) features.push("t:" + tag.toLowerCase());
|
|
2654
|
-
if (STATE_PSEUDO.test(plain)) features.push(":state");
|
|
2655
|
-
// Nothing but a universal selector or pseudo-classes: it can match anything.
|
|
2656
|
-
if (!features.length || /^\*/.test(plain)) features.push("*");
|
|
2657
|
-
return features;
|
|
2658
|
-
}
|
|
2659
|
-
|
|
2660
|
-
/** The features an element has (for a sibling it inserts next to, for example). */
|
|
2661
|
-
function elementFeatures(element) {
|
|
2662
|
-
const features = ["t:" + element.localName];
|
|
2663
|
-
const id = element.getAttribute("id");
|
|
2664
|
-
if (id) features.push("#" + id);
|
|
2665
|
-
for (const name of element.classList) features.push("." + name);
|
|
2666
|
-
for (const attribute of element.attributes) features.push("[" + attribute.name);
|
|
2667
|
-
return features;
|
|
2668
|
-
}
|
|
2669
|
-
|
|
2670
|
-
/** A complex selector's compounds with the combinator after each (" ", ">", "+", "~", or null for the subject). */
|
|
2671
|
-
function complexParts(selector) {
|
|
2672
|
-
const parts = [];
|
|
2673
|
-
let depth = 0;
|
|
2674
|
-
let quote = "";
|
|
2675
|
-
let current = "";
|
|
2676
|
-
let combinator = null;
|
|
2677
|
-
const flush = () => {
|
|
2678
|
-
if (current) parts.push({ compound: current, combinator: null });
|
|
2679
|
-
current = "";
|
|
2680
|
-
};
|
|
2681
|
-
for (let index = 0; index < selector.length; index++) {
|
|
2682
|
-
const character = selector[index];
|
|
2683
|
-
if (quote) { current += character; if (character === "\\") current += selector[++index] ?? ""; else if (character === quote) quote = ""; continue; }
|
|
2684
|
-
if (character === '"' || character === "'") quote = character;
|
|
2685
|
-
if (character === "\\") { current += character + (selector[++index] ?? ""); continue; }
|
|
2686
|
-
if (character === "(" || character === "[") depth++;
|
|
2687
|
-
else if (character === ")" || character === "]") depth--;
|
|
2688
|
-
if (depth === 0 && /[\s>+~]/.test(character)) {
|
|
2689
|
-
flush();
|
|
2690
|
-
combinator = character.trim() ? character : combinator ?? " ";
|
|
2691
|
-
if (character.trim() && parts.length) parts.at(-1).combinator = character;
|
|
2692
|
-
else if (parts.length && parts.at(-1).combinator === null) parts.at(-1).combinator = " ";
|
|
2693
|
-
continue;
|
|
2694
|
-
}
|
|
2695
|
-
current += character;
|
|
2696
|
-
}
|
|
2697
|
-
flush();
|
|
2698
|
-
return parts;
|
|
2699
|
-
}
|
|
2700
|
-
|
|
2701
|
-
/** The compound selectors of a complex selector (split at top-level combinators). */
|
|
2702
|
-
function compoundsOf(selector) {
|
|
2703
|
-
const compounds = [];
|
|
2704
|
-
let depth = 0;
|
|
2705
|
-
let quote = "";
|
|
2706
|
-
let current = "";
|
|
2707
|
-
for (let index = 0; index < selector.length; index++) {
|
|
2708
|
-
const character = selector[index];
|
|
2709
|
-
if (quote) { current += character; if (character === "\\") current += selector[++index] ?? ""; else if (character === quote) quote = ""; continue; }
|
|
2710
|
-
if (character === '"' || character === "'") quote = character;
|
|
2711
|
-
if (character === "\\") { current += character + (selector[++index] ?? ""); continue; }
|
|
2712
|
-
if (character === "(" || character === "[") depth++;
|
|
2713
|
-
else if (character === ")" || character === "]") depth--;
|
|
2714
|
-
if (depth === 0 && /[\s>+~]/.test(character)) {
|
|
2715
|
-
if (current) compounds.push(current);
|
|
2716
|
-
current = "";
|
|
2717
|
-
continue;
|
|
2718
|
-
}
|
|
2719
|
-
current += character;
|
|
2720
|
-
}
|
|
2721
|
-
if (current) compounds.push(current);
|
|
2722
|
-
return compounds;
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
2687
|
function canvasColor(CanvasKit, value) {
|
|
2726
2688
|
const match = /^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/.exec(value);
|
|
2727
2689
|
return match
|