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.
@@ -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 { layoutText } from "../layout/text-layout.js";
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 styleChanges.roots.add(element);
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
- const text = String(sheet).replace(/\/\*[\s\S]*?\*\//g, "");
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)) { state.set(compound, previous.get(compound)); continue; }
980
- try { state.set(compound, new Set(page.document.querySelectorAll(compound))); } catch { /* invalid: never matches */ }
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
- for (const state of [hasState, structuralState, siblingState]) {
992
- for (const [compound, matched] of state ?? []) {
993
- let inside;
994
- try {
995
- inside = new Set(root.querySelectorAll(compound));
996
- if (root.matches(compound)) inside.add(root);
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.matches(compound); } catch {}
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.matches(compound); } catch {}
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
- if (features.some((feature) => traits.descendantFeatures.has(feature))) roots.add(element);
1088
- if (traits.siblingFeatures.has("*") || features.some((feature) => traits.siblingFeatures.has(feature))) later(element);
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
- page.renderTree = renderTreeBuilder.build(page.document, styleEngine, page.resources);
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
- page.layout = layoutText(page.renderTree, {
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) for (const element of page.document.getElementsByTagName(String(name))) markStyleDirty(element);
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 response = await fetchResource(url, "script");
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.dirty = true;
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