buninu-browser 0.5.1 → 0.6.0

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.
Files changed (133) hide show
  1. package/README.md +9 -3
  2. package/lib/audio/pcm.js +11 -0
  3. package/lib/audio/pulse-client.js +7 -2
  4. package/lib/cdp-server.js +31 -7
  5. package/lib/happy-dom/document-base.js +29 -1
  6. package/lib/happy-dom/mutation-observer.js +157 -0
  7. package/lib/happy-dom/parser.js +2 -0
  8. package/lib/headless-shell.js +49 -9
  9. package/lib/layout/repaint.js +89 -0
  10. package/lib/media/mp4-keyframes.js +144 -0
  11. package/lib/media/video-keyframe.js +29 -1
  12. package/lib/media/webm-snapshots.js +282 -0
  13. package/lib/network/cookie-jar.js +18 -2
  14. package/lib/paint/display-list.js +4 -3
  15. package/lib/paint/frame.js +12 -5
  16. package/lib/render-tree/index.js +3 -0
  17. package/lib/renderer/animation-frames.js +76 -0
  18. package/lib/renderer/bindings.js +2 -0
  19. package/lib/renderer/compositor-channel.js +95 -10
  20. package/lib/renderer/compositor.js +54 -18
  21. package/lib/renderer/document-cookies.js +87 -0
  22. package/lib/renderer/font-loading.js +60 -16
  23. package/lib/renderer/host.js +48 -19
  24. package/lib/renderer/idle-callbacks.js +109 -0
  25. package/lib/renderer/main.js +5 -1
  26. package/lib/renderer/media-source.js +4 -0
  27. package/lib/renderer/page-realm.js +159 -49
  28. package/lib/renderer/page-renderer.js +567 -197
  29. package/lib/renderer/resource-elements.js +88 -0
  30. package/lib/renderer/script-rewrite.js +18 -1
  31. package/lib/renderer/seccomp.js +6 -4
  32. package/lib/renderer/timers.js +71 -0
  33. package/lib/renderer/video-channel.js +94 -0
  34. package/lib/renderer/video-mask.js +115 -0
  35. package/lib/renderer/video-presentation.js +304 -39
  36. package/lib/renderer/video-worker.js +46 -0
  37. package/lib/renderer/workers.js +7 -17
  38. package/lib/style/computed-style.js +257 -61
  39. package/package.json +1 -1
  40. package/test/animation-frames.test.js +67 -0
  41. package/test/canvas-style-snapshot.test.js +18 -0
  42. package/test/cdp-stream.test.js +96 -3
  43. package/test/compositor-channel-ownership.test.js +194 -0
  44. package/test/compositor.test.js +27 -1
  45. package/test/computed-style-binding.test.js +49 -0
  46. package/test/computed-style-connectivity.test.js +13 -0
  47. package/test/computed-style.test.js +86 -0
  48. package/test/cookie-sync.test.js +82 -0
  49. package/test/document-base-cache.test.js +103 -0
  50. package/test/document-cookies.test.js +117 -0
  51. package/test/duplicate-style-keys.test.js +11 -0
  52. package/test/fixtures/animation-frames.js +56 -0
  53. package/test/fixtures/canvas-style-snapshot.js +81 -0
  54. package/test/fixtures/computed-style-connectivity.js +101 -0
  55. package/test/fixtures/computed-style-lazy.js +135 -0
  56. package/test/fixtures/duplicate-style-keys.js +97 -0
  57. package/test/fixtures/fake-renderer.js +12 -0
  58. package/test/fixtures/font-definition-invalidation.js +97 -0
  59. package/test/fixtures/font-metrics-invalidation.js +87 -0
  60. package/test/fixtures/full-style-identity.js +36 -0
  61. package/test/fixtures/hidden-style-engine.js +260 -0
  62. package/test/fixtures/hidden-style-reentry.js +232 -0
  63. package/test/fixtures/hidden-style-renderer.js +203 -0
  64. package/test/fixtures/layout-repaint.js +69 -0
  65. package/test/fixtures/media-presentation-continuation.js +141 -0
  66. package/test/fixtures/media-presentation-refill.js +113 -0
  67. package/test/fixtures/media-probe.js +148 -0
  68. package/test/fixtures/media-refill-cancel.js +79 -0
  69. package/test/fixtures/media-repaint.js +48 -0
  70. package/test/fixtures/media-resource-reselection.js +94 -0
  71. package/test/fixtures/media-source-attachment.js +136 -0
  72. package/test/fixtures/media-source-no-decoder.js +17 -0
  73. package/test/fixtures/media-track-refill.js +132 -0
  74. package/test/fixtures/media-webm-seek.js +51 -0
  75. package/test/fixtures/mutation-observer.js +201 -0
  76. package/test/fixtures/observer-invalidation.js +105 -0
  77. package/test/fixtures/prepaint-scheduling.js +73 -0
  78. package/test/fixtures/resource-discovery.js +32 -0
  79. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  80. package/test/fixtures/style-propagation.js +26 -0
  81. package/test/fixtures/svg-node-reuse.js +83 -0
  82. package/test/fixtures/video-black-replay.js +140 -0
  83. package/test/fixtures/video-controls-invalidation.js +71 -0
  84. package/test/fixtures/viewport-bounds.js +148 -0
  85. package/test/font-loading.test.js +77 -0
  86. package/test/font-metrics-invalidation.test.js +60 -0
  87. package/test/full-style-identity.test.js +191 -0
  88. package/test/hidden-style-lazy.test.js +31 -0
  89. package/test/hidden-style-reentry.test.js +20 -0
  90. package/test/idle-callback.test.js +89 -0
  91. package/test/idle-scheduler.test.js +148 -0
  92. package/test/inline-style-cascade.test.js +63 -0
  93. package/test/layout-repaint-renderer.test.js +9 -0
  94. package/test/layout-repaint.test.js +222 -0
  95. package/test/media-presentation-continuation.test.js +14 -0
  96. package/test/media-presentation-refill.test.js +8 -0
  97. package/test/media-probe.test.js +16 -0
  98. package/test/media-repaint.test.js +8 -0
  99. package/test/media-source-attachment.test.js +34 -0
  100. package/test/media-track-refill.test.js +26 -0
  101. package/test/media-webm-seek.test.js +11 -0
  102. package/test/mp4-keyframes.test.js +36 -0
  103. package/test/mutation-observer.test.js +91 -0
  104. package/test/observer-invalidation.test.js +12 -0
  105. package/test/page-scripts.test.js +35 -0
  106. package/test/page-timers.test.js +67 -0
  107. package/test/paint-frame.test.js +18 -1
  108. package/test/pcm.test.js +63 -0
  109. package/test/prepaint-scheduling.test.js +13 -0
  110. package/test/pulse-client.test.js +165 -0
  111. package/test/raw-capture.test.js +34 -5
  112. package/test/renderer-host.test.js +51 -0
  113. package/test/resource-discovery.test.js +12 -0
  114. package/test/resource-elements.test.js +234 -0
  115. package/test/root-self-style.test.js +104 -0
  116. package/test/script-rewrite.test.js +35 -0
  117. package/test/seccomp.test.js +45 -0
  118. package/test/spare-renderer-scheduling.test.js +8 -0
  119. package/test/style-propagation.test.js +225 -0
  120. package/test/style-sheets.test.js +19 -0
  121. package/test/svg.test.js +45 -0
  122. package/test/timers.test.js +58 -0
  123. package/test/video-black-replay.test.js +34 -0
  124. package/test/video-capture.test.js +170 -0
  125. package/test/video-channel.test.js +253 -0
  126. package/test/video-continuation.test.js +57 -0
  127. package/test/video-controls-invalidation.test.js +12 -0
  128. package/test/video-keyframe.test.js +104 -4
  129. package/test/video-mask.test.js +94 -0
  130. package/test/video-presentation.test.js +239 -1
  131. package/test/video-timing-presentation.test.js +115 -0
  132. package/test/viewport-bounds.test.js +13 -0
  133. package/test/webm-snapshots.test.js +372 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Cooperative idle work shares a short period, rather than granting every
3
+ * queued callback a fresh budget. Dispatch one callback per host task so
4
+ * ordinary timers and input can run between callbacks. A callback registered
5
+ * during a period joins the next one; positive timeouts have their own task.
6
+ * Clock/timer injection keeps the fairness and lifetime tests deterministic.
7
+ */
8
+ export function createIdleCallbacks({ now, setTimer, clearTimer, invoke }) {
9
+ const callbacks = new Map();
10
+ let runnable = [], next = 0, deadline = 0, timer = null;
11
+ let running = false, disposed = false;
12
+
13
+ function queue() {
14
+ if (disposed) return;
15
+ if (!callbacks.size) {
16
+ if (timer !== null) clearTimer(timer);
17
+ timer = null;
18
+ runnable = []; next = 0;
19
+ return;
20
+ }
21
+ if (running || timer !== null) return;
22
+ // A nested request must not start a new period before this one ends.
23
+ const delay = next >= runnable.length ? Math.max(1, deadline - now()) : 1;
24
+ timer = setTimer(dispatch, delay);
25
+ }
26
+
27
+ function remove(record) {
28
+ if (callbacks.get(record.id) !== record) return false;
29
+ callbacks.delete(record.id);
30
+ if (record.timer !== null) clearTimer(record.timer);
31
+ record.timer = null;
32
+ return true;
33
+ }
34
+
35
+ function deliver(record, didTimeout, until) {
36
+ if (!remove(record)) return;
37
+ invoke(record.callback, {
38
+ get didTimeout() { return didTimeout; },
39
+ timeRemaining: () => Math.max(0, until - now()),
40
+ });
41
+ }
42
+
43
+ function queueTimeout(record) {
44
+ // Web IDL permits unsigned-long timeouts, while host timers only accept a
45
+ // signed 32-bit delay. Chunk longer waits instead of overflowing to 1ms.
46
+ record.timer = setTimer(() => {
47
+ record.timer = null;
48
+ if (disposed || callbacks.get(record.id) !== record) return;
49
+ if (now() < record.expires) { queueTimeout(record); return; }
50
+ running = true;
51
+ try { deliver(record, true, now()); }
52
+ finally {
53
+ running = false;
54
+ queue();
55
+ }
56
+ }, Math.min(2147483647, Math.max(1, record.expires - now())));
57
+ }
58
+
59
+ function dispatch() {
60
+ timer = null;
61
+ if (disposed || !callbacks.size) return;
62
+ running = true;
63
+ try {
64
+ const at = now();
65
+ if (at >= deadline) {
66
+ runnable = [...callbacks.values()];
67
+ next = 0;
68
+ deadline = at + 10;
69
+ }
70
+ while (next < runnable.length && callbacks.get(runnable[next].id) !== runnable[next]) next++;
71
+ if (next < runnable.length) {
72
+ const record = runnable[next++], at = now();
73
+ const didTimeout = at >= record.expires;
74
+ deliver(record, didTimeout, didTimeout ? at : deadline);
75
+ }
76
+ } finally {
77
+ running = false;
78
+ queue();
79
+ }
80
+ }
81
+
82
+ return {
83
+ request(id, callback, timeout) {
84
+ if (disposed) return id;
85
+ const record = { id, callback, expires: timeout > 0 ? now() + timeout : Infinity, timer: null };
86
+ callbacks.set(id, record);
87
+ if (timeout > 0) queueTimeout(record);
88
+ queue();
89
+ return id;
90
+ },
91
+ cancel(id) {
92
+ const record = callbacks.get(id);
93
+ if (record) remove(record);
94
+ if (!callbacks.size) {
95
+ if (timer !== null) clearTimer(timer);
96
+ timer = null;
97
+ runnable = []; next = 0;
98
+ }
99
+ },
100
+ dispose() {
101
+ disposed = true;
102
+ if (timer !== null) clearTimer(timer);
103
+ timer = null;
104
+ for (const record of callbacks.values()) if (record.timer !== null) clearTimer(record.timer);
105
+ callbacks.clear();
106
+ runnable = []; next = 0;
107
+ },
108
+ };
109
+ }
@@ -265,7 +265,7 @@ const renderer = await createPageRenderer({
265
265
  clipboardWriteInput:text=>clipboardWrite(text,'clipboard-write-input'),
266
266
  webSockets,
267
267
  audio,
268
- setCookie: (value) => process.send({ type: "cookie", value }),
268
+ setCookie: (value, url, sequence) => process.send({ type: "cookie", value, url, sequence }),
269
269
  reportError: (message) => report(`buninu-browser: page error: ${logging ? message : String(message).split("\n")[0]}`),
270
270
  consoleMessage: logging ? (level, text) => report(`page console.${level}: ${text}`) : null,
271
271
  trace: logging ? (text) => report(`timing: ${text}`) : null,
@@ -352,6 +352,10 @@ async function warmUp() {
352
352
  }
353
353
 
354
354
  process.on("message", async (message) => {
355
+ if (message?.type === "cookie-state") {
356
+ renderer.updateCookies(message.state);
357
+ return;
358
+ }
355
359
  if(message?.type==='clipboard-result') {
356
360
  const pending=clipboardWrites.get(message.id);if(!pending)return;
357
361
  clipboardWrites.delete(message.id);clearTimeout(pending.timer);
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { createBytePipe } from "../media/byte-pipe.js";
16
16
  import { createSourceSnapshots } from "../media/source-snapshots.js";
17
+ import { createWebMAudioSnapshots } from "../media/webm-snapshots.js";
17
18
  import { createWebMStream, ebmlHeader } from "../media/webm-stream.js";
18
19
 
19
20
  const BRAND = Symbol("MediaSource");
@@ -444,6 +445,8 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
444
445
  this._webm = {};
445
446
  this._webmCovered = new Map();
446
447
  this._webmStream = /^(video|audio)\/webm\b/i.test(this._mime) ? createWebMStream() : null;
448
+ this._webmSnapshots = /^audio\/webm\b/i.test(this._mime) ? createWebMAudioSnapshots() : null;
449
+ if (this._webmSnapshots) this._pipe.snapshotAt = time => this._webmSnapshots.at(time, () => this._pipe.bytes());
447
450
  this._hasSegmentTimeline = false;
448
451
  this._hasMediaSegment = false;
449
452
  this._appendWindowStart = 0;
@@ -589,6 +592,7 @@ export function createMediaSourceScope({ origin, schedule, DOMException, onAppen
589
592
  addCovered(this._covered, start, end);
590
593
  }
591
594
  }
595
+ this._webmSnapshots?.append({ id, bytes, cluster });
592
596
  if (cluster && this._webmCluster !== cluster.token) {
593
597
  for (const prefix of cluster.prefix) this._pipe.append(prefix);
594
598
  this._webmCluster = cluster.token;
@@ -22,10 +22,12 @@ import {
22
22
  rewriteClassicScriptSource, rewriteConstructorReads, rewriteModuleSource,
23
23
  } from "./script-rewrite.js";
24
24
  import { prepareClassicScript } from "./prepared-script-cache.js";
25
- import { mediaQueryMatches, resolveVariables } from "../style/computed-style.js";
25
+ import { computeElementStyle, mediaQueryMatches, resolveVariables } from "../style/computed-style.js";
26
26
  import { asIncumbent, createMessaging } from "./messaging.js";
27
27
  import { canvasContext, resetCanvas, disposeDocumentCanvases, encodeCanvas } from "./canvas-2d.js";
28
28
  import { createWorkers } from "./workers.js";
29
+ import { createTimers } from "./timers.js";
30
+ import { createAnimationFrames, supportsFrameCheckpoint } from "./animation-frames.js";
29
31
  import { createMediaSourceScope } from "./media-source.js";
30
32
  import { markNative, markNativeApi, setFunctionSourceTextTransform } from "./native-functions.js";
31
33
  import { applyGlobalShape, errorInterface, platformObject, storageInterface } from "./interfaces.js";
@@ -33,6 +35,7 @@ import { trustedTypesApi } from "./trusted-types.js";
33
35
  import { onPromiseRejection } from "./rejections.js";
34
36
  import { installAttributeReactions } from "./custom-element-reactions.js";
35
37
  import { createLayoutObservers } from "./layout-observers.js";
38
+ import { createIdleCallbacks } from "./idle-callbacks.js";
36
39
  import { createBroadcastChannels } from "./broadcast-channel.js";
37
40
  import { createInertWindow } from "../happy-dom/parser.js";
38
41
  import { historyBinding } from "./history.js";
@@ -257,8 +260,8 @@ function cssEscape(value) {
257
260
  * boundsOf(element: object): { left: number, top: number, width: number, height: number } | null,
258
261
  * viewport(): { width: number, height: number, scrollX: number, scrollY: number, devicePixelRatio: number },
259
262
  * scrollTo(x: number, y: number): void,
260
- * computedStyle(element: object): object | null,
261
- * customProperties?(element: object): Map<string, string> | null,
263
+ * computedStyle(element: object, needsLayout?: boolean): object | null,
264
+ * customProperties?(element: object, updateStyle?: boolean): Map<string, string> | null,
262
265
  * navigate(url: string): void,
263
266
  * activateLink?(anchor: object): void,
264
267
  * fetchScript(url: string): Promise<string>,
@@ -271,7 +274,6 @@ function cssEscape(value) {
271
274
  * }} options
272
275
  */
273
276
  export function createPageRealm({ window, document, mode, hooks }) {
274
- const timers = new Map();
275
277
  // Close functions of this document's open WebSockets (see networkApi)
276
278
  const openSockets = new Set();
277
279
  // Happy DOM runs its own document lifecycle and fires load/DOMContentLoaded
@@ -372,6 +374,7 @@ export function createPageRealm({ window, document, mode, hooks }) {
372
374
  playMedia: hooks.playMedia,
373
375
  pauseMedia: hooks.pauseMedia,
374
376
  seekMedia: hooks.seekMedia,
377
+ reselectMedia: hooks.reselectMedia,
375
378
  attachMediaSrc: (element, value) => hooks.attachMediaSrc?.(element, value),
376
379
  mediaReadyState: (element) => hooks.mediaReadyState?.(element) ?? 0,
377
380
  mediaNetworkState: (element) => hooks.mediaNetworkState?.(element) ?? 0,
@@ -742,25 +745,23 @@ export function createPageRealm({ window, document, mode, hooks }) {
742
745
  target.dispatchEvent(event);
743
746
  };
744
747
 
745
- const schedule = (handler, delay, args, repeat) => {
746
- const id = nextTimerId++;
748
+ const timers = createTimers({ invoke: callback => runTask(callback), nextId: () => nextTimerId++ });
749
+ const schedule = (handler, delay, args, repeat, timerTask = true) => {
750
+ const code = typeof handler === "function" ? null : String(handler);
747
751
  const callback = typeof handler === "function"
748
752
  ? () => callPage(handler, pageGlobal, args)
749
- : () => evaluate(String(handler), "timer");
750
- const fire = () => {
751
- if (!timers.has(id)) return;
752
- if (repeat) timers.set(id, hostSetTimeout(fire, Math.max(4, delay)));
753
- else timers.delete(id);
754
- runTask(callback);
755
- };
756
- timers.set(id, hostSetTimeout(fire, Math.max(0, Number(delay) || 0)));
757
- return id;
758
- };
759
- const cancel = (id) => {
760
- const handle = timers.get(Number(id));
761
- if (handle) hostClearTimeout(handle);
762
- timers.delete(Number(id));
753
+ : () => evaluate(code, "timer");
754
+ return timers.schedule(callback, delay, repeat, timerTask);
763
755
  };
756
+ const cancel = id => timers.cancel(id);
757
+ const animationFrames = supportsFrameCheckpoint() ? createAnimationFrames({
758
+ invoke: (callback, timestamp) => runTask(() => callPage(callback, pageGlobal, [timestamp])),
759
+ now: pageNow,
760
+ }) : null;
761
+ const idleCallbacks = createIdleCallbacks({
762
+ now: () => hostPerformance.now(), setTimer: hostSetTimeout, clearTimer: hostClearTimeout,
763
+ invoke: (callback, deadline) => runTask(() => callPage(callback, pageGlobal, [deadline])),
764
+ });
764
765
 
765
766
  const pageConsole = Object.fromEntries(["log", "info", "warn", "error", "debug", "trace"].map((level) => [
766
767
  level,
@@ -801,13 +802,14 @@ export function createPageRealm({ window, document, mode, hooks }) {
801
802
 
802
803
  const layoutObservers = createLayoutObservers({
803
804
  nodeOf, boundsOf: hooks.boundsOf, computedStyle: hooks.computedStyle, viewport: hooks.viewport,
804
- schedule: (callback) => schedule(callback, 0, [], false), cancel, call: callPage, now: pageNow,
805
+ schedule: (callback) => schedule(callback, 0, [], false, false), cancel, call: callPage, now: pageNow,
805
806
  });
806
807
 
807
808
  /** MutationObserver wraps Happy DOM records; layout observers use renderer geometry. */
808
809
  function observerConstructors() {
809
810
  function MutationObserver(callback) {
810
811
  if (!new.target) throw new TypeError("Constructor MutationObserver requires 'new'");
812
+ if (typeof callback !== "function") throw new TypeError("MutationObserver callback must be a function");
811
813
  const self = {};
812
814
  const wrapRecord = (record) => ({
813
815
  type: record.type,
@@ -825,15 +827,22 @@ export function createPageRealm({ window, document, mode, hooks }) {
825
827
  self.observe = (target, options = {}) => {
826
828
  const node = nodeOf(target);
827
829
  if (!node) throw new TypeError("MutationObserver.observe requires a Node");
828
- real.observe(node, {
829
- childList: Boolean(options.childList),
830
- attributes: Boolean(options.attributes ?? options.attributeFilter ?? options.attributeOldValue),
831
- characterData: Boolean(options.characterData ?? options.characterDataOldValue),
832
- subtree: Boolean(options.subtree),
833
- attributeOldValue: Boolean(options.attributeOldValue),
834
- characterDataOldValue: Boolean(options.characterDataOldValue),
835
- ...(Array.isArray(options.attributeFilter) ? { attributeFilter: options.attributeFilter.map(String) } : {}),
836
- });
830
+ const normalized = {};
831
+ const filter = options?.attributeFilter;
832
+ if (filter !== undefined) {
833
+ if (filter === null || (typeof filter !== "object" && typeof filter !== "function")) {
834
+ throw new TypeError("MutationObserver attributeFilter must be iterable");
835
+ }
836
+ normalized.attributeFilter = [];
837
+ for (const name of filter) normalized.attributeFilter.push(`${name}`);
838
+ }
839
+ // Omitted boolean members carry different semantics from false: e.g.
840
+ // attributeOldValue:false with no attributes member enables attributes.
841
+ for (const name of ["attributeOldValue", "attributes", "characterData", "characterDataOldValue", "childList", "subtree"]) {
842
+ const value = options?.[name];
843
+ if (value !== undefined) normalized[name] = Boolean(value);
844
+ }
845
+ real.observe(node, normalized);
837
846
  };
838
847
  self.disconnect = () => real.disconnect();
839
848
  self.takeRecords = () => real.takeRecords().map(wrapRecord);
@@ -1642,11 +1651,21 @@ export function createPageRealm({ window, document, mode, hooks }) {
1642
1651
  // Each is its own function with its Web IDL length (handles are optional for the clears).
1643
1652
  clearTimeout: (id = undefined) => cancel(id),
1644
1653
  clearInterval: (id = undefined) => cancel(id),
1645
- requestAnimationFrame: (callback) => schedule(() => callPage(callback, pageGlobal, [pageNow()]), 16, [], false),
1646
- cancelAnimationFrame: (handle) => cancel(handle),
1647
- requestIdleCallback: (callback) => schedule(
1648
- () => callPage(callback, pageGlobal, [{ didTimeout: false, timeRemaining: () => 10 }]), 1, [], false),
1649
- cancelIdleCallback: (handle) => cancel(handle),
1654
+ requestAnimationFrame: (callback) => animationFrames
1655
+ ? animationFrames.request(callback)
1656
+ : schedule(() => callPage(callback, pageGlobal, [pageNow()]), 16, [], false, false),
1657
+ cancelAnimationFrame: (handle) => animationFrames ? animationFrames.cancel(handle) : cancel(handle),
1658
+ requestIdleCallback: (callback, options = undefined) => {
1659
+ if (typeof callback !== "function") throw new TypeError("requestIdleCallback: callback is not a function");
1660
+ if (options != null && typeof options !== "object" && typeof options !== "function") {
1661
+ throw new TypeError("requestIdleCallback: options must be a dictionary");
1662
+ }
1663
+ // IdleRequestOptions.timeout is an unsigned long; zero does not start
1664
+ // a timeout. Idle callbacks share short periods and yield between tasks.
1665
+ const timeout = options?.timeout >>> 0;
1666
+ return idleCallbacks.request(nextTimerId++, callback, timeout);
1667
+ },
1668
+ cancelIdleCallback: (handle) => idleCallbacks.cancel(Number(handle)),
1650
1669
  queueMicrotask: (callback) => hostQueueMicrotask(() => runTask(() => callPage(callback, pageGlobal, []))),
1651
1670
  structuredClone: (value) => hostStructuredClone(value),
1652
1671
  atob: (value) => hostAtob(String(value)),
@@ -1781,11 +1800,17 @@ export function createPageRealm({ window, document, mode, hooks }) {
1781
1800
  dispatchEvent: windowEvents.dispatchEvent,
1782
1801
  getComputedStyle: (element) => {
1783
1802
  const node = nodeOf(element);
1784
- const style = hooks.computedStyle(node);
1785
- return computedStyleBinding(style, {
1786
- custom: hooks.customProperties?.(node) ?? null,
1787
- bounds: style && style.display !== "none" && node?.isConnected ? hooks.boundsOf(node) : null,
1788
- element: node,
1803
+ return computedStyleBinding((needsLayout) => {
1804
+ // Detached elements have no resolved values, even if the style engine
1805
+ // still retains their last connected style. Check every live read so
1806
+ // an existing declaration also empties on removal and revives on attach.
1807
+ if (!node?.isConnected) return { style: null, custom: null, bounds: null, element: node };
1808
+ const style = hooks.computedStyle(node, needsLayout);
1809
+ return {
1810
+ style, custom: hooks.customProperties?.(node, false) ?? null,
1811
+ bounds: needsLayout && style && style.display !== "none" && node?.isConnected ? hooks.boundsOf(node) : null,
1812
+ element: node,
1813
+ };
1789
1814
  });
1790
1815
  },
1791
1816
  getSelection: () => realm.selection(),
@@ -2454,8 +2479,9 @@ export function createPageRealm({ window, document, mode, hooks }) {
2454
2479
  // Leaving the document closes its WebSockets (1001 "going away").
2455
2480
  for (const close of openSockets) close();
2456
2481
  openSockets.clear();
2457
- for (const handle of timers.values()) hostClearTimeout(handle);
2458
- timers.clear();
2482
+ timers.dispose();
2483
+ animationFrames?.dispose();
2484
+ idleCallbacks.dispose();
2459
2485
  },
2460
2486
  };
2461
2487
  }
@@ -2856,7 +2882,78 @@ function platformObjects({ eventTargets, userAgent, userAgentDataMembers, mobile
2856
2882
  };
2857
2883
  }
2858
2884
 
2859
- /** A read-only snapshot of the few computed values the engine models. */
2885
+ // CSSOM returns a live declaration. Like Blink's CSSComputedStyleDeclaration,
2886
+ // resolve style first and update layout only for layout-dependent properties.
2887
+ const COMPUTED_LAYOUT_PROPERTIES = new Set([
2888
+ "width", "height", "top", "right", "bottom", "left", "transform",
2889
+ "margin", "marginTop", "marginRight", "marginBottom", "marginLeft",
2890
+ "padding", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
2891
+ ]);
2892
+ const COMPUTED_PADDING_PROPERTIES = new Map([
2893
+ ["padding", -1], ["paddingTop", 0], ["paddingRight", 1], ["paddingBottom", 2], ["paddingLeft", 3],
2894
+ ]);
2895
+ // Populated once from the snapshot's fixed internal fields, never from page
2896
+ // input. Reuse the CSS/JS spelling table across elements and live snapshots.
2897
+ const COMPUTED_CSS_PROPERTIES = new Map();
2898
+ let computedStylePropertyNames = null;
2899
+ let computedStyleAccessorNames = null;
2900
+ const COMPUTED_STYLE_SCHEMA_ELEMENT = Object.freeze({
2901
+ tagName: "DIV", getAttribute: () => null, hasAttribute: () => false,
2902
+ });
2903
+ // Computed styles and custom-property maps retain their identities until
2904
+ // restyle changes them. Share serialization across repeated declarations;
2905
+ // used geometry remains local to each live declaration and its bounds.
2906
+ const computedStyleSnapshots = new WeakMap();
2907
+
2908
+ function computedStyleBinding(read) {
2909
+ const initial = read(false);
2910
+ const shape = computedStyleSnapshot(initial.style, initial);
2911
+ // A declaration first obtained while detached still exposes the ordinary
2912
+ // named accessors. Derive their schema once from the same serializer, without
2913
+ // resolving the detached node or duplicating its supported-property list.
2914
+ if (computedStyleAccessorNames === null) {
2915
+ computedStyleSnapshot(computeElementStyle(COMPUTED_STYLE_SCHEMA_ELEMENT));
2916
+ }
2917
+ const snapshots = [{ ...initial, value: shape }, null];
2918
+ const snapshot = name => {
2919
+ const property = name.startsWith("--") ? null : COMPUTED_CSS_PROPERTIES.get(name) ?? name;
2920
+ let layout = COMPUTED_LAYOUT_PROPERTIES.has(property);
2921
+ let state;
2922
+ const side = COMPUTED_PADDING_PROPERTIES.get(property);
2923
+ if (side !== undefined) {
2924
+ state = read(false);
2925
+ const padding = state.style?.padding;
2926
+ // CSSOM resolves fixed padding directly from computed style. Percentages
2927
+ // and unresolved math still need the containing block's used geometry.
2928
+ if (padding && (side < 0 ? padding.every(value => typeof value === "number")
2929
+ : typeof padding[side] === "number")) layout = false;
2930
+ }
2931
+ if (!state || layout) state = read(layout);
2932
+ const prior = snapshots[Number(layout)];
2933
+ const bounds = state.bounds;
2934
+ if (prior && prior.style === state.style && prior.custom === state.custom
2935
+ && (prior.bounds === bounds || (prior.bounds && bounds
2936
+ && prior.bounds.width === bounds.width && prior.bounds.height === bounds.height))) return prior.value;
2937
+ const value = computedStyleSnapshot(state.style, state);
2938
+ snapshots[Number(layout)] = { ...state, value };
2939
+ return value;
2940
+ };
2941
+ const declaration = {
2942
+ get length() { return snapshot("").length; },
2943
+ item(index) { return snapshot("").item(index); },
2944
+ getPropertyValue(name) {
2945
+ const key = String(name);
2946
+ return snapshot(key.startsWith("--") ? key : key.toLowerCase()).getPropertyValue(key);
2947
+ },
2948
+ getPropertyPriority() { return ""; },
2949
+ get cssText() { return ""; },
2950
+ };
2951
+ for (const name of computedStyleAccessorNames) Object.defineProperty(declaration, name, {
2952
+ enumerable: true, configurable: true, get: () => snapshot(name)[name] ?? "",
2953
+ });
2954
+ return declaration;
2955
+ }
2956
+
2860
2957
  /**
2861
2958
  * The page's CSSStyleDeclaration for getComputedStyle (CSSOM §9): resolved
2862
2959
  * values for the longhands the engine computes, by camelCase name and by
@@ -2864,7 +2961,10 @@ function platformObjects({ eventTargets, userAgent, userAgentDataMembers, mobile
2864
2961
  * are used values (layout's box) when the element is rendered; percentages
2865
2962
  * that depend on layout read as specified otherwise.
2866
2963
  */
2867
- function computedStyleBinding(style, { custom = null, bounds = null, element = null } = {}) {
2964
+ function computedStyleSnapshot(style, { custom = null, bounds = null, element = null } = {}) {
2965
+ const reusable = !bounds && style && Object.isFrozen(style);
2966
+ const cached = reusable && computedStyleSnapshots.get(style);
2967
+ if (cached && cached.custom === custom) return cached.value;
2868
2968
  const px = (value) => `${Math.round(value * 1000) / 1000}px`;
2869
2969
  const length = (value) => typeof value === "number" ? px(value)
2870
2970
  : value?.unit === "%" ? `${value.value}%`
@@ -2975,14 +3075,20 @@ function computedStyleBinding(style, { custom = null, bounds = null, element = n
2975
3075
  values.margin = sides.map((side) => values[`margin${side}`]).join(" ");
2976
3076
  values.padding = sides.map((side) => values[`padding${side}`]).join(" ");
2977
3077
  }
2978
- const kebab = (name) => name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
2979
- const byKebab = Object.fromEntries(Object.entries(values).map(([name, value]) => [kebab(name), value]));
2980
- const names = Object.keys(byKebab);
3078
+ if (style && computedStylePropertyNames === null) {
3079
+ computedStyleAccessorNames = [...Object.keys(values), "webkitLineClamp", "webkitBoxOrient"];
3080
+ computedStylePropertyNames = Object.keys(values).map(name => {
3081
+ const cssName = name.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
3082
+ COMPUTED_CSS_PROPERTIES.set(cssName, name);
3083
+ return cssName;
3084
+ });
3085
+ }
3086
+ const names = style ? computedStylePropertyNames : [];
2981
3087
  const customValue = (name) => {
2982
3088
  const value = custom?.get(name);
2983
3089
  return value == null ? "" : resolveVariables(value, custom);
2984
3090
  };
2985
- return {
3091
+ const result = {
2986
3092
  ...values,
2987
3093
  webkitLineClamp: values.WebkitLineClamp,
2988
3094
  webkitBoxOrient: values.WebkitBoxOrient,
@@ -2990,13 +3096,17 @@ function computedStyleBinding(style, { custom = null, bounds = null, element = n
2990
3096
  item: (index) => names[Number(index)] ?? "",
2991
3097
  getPropertyValue: (name) => {
2992
3098
  const key = String(name);
2993
- return key.startsWith("--") ? customValue(key) : byKebab[key.toLowerCase()] ?? "";
3099
+ if (key.startsWith("--")) return customValue(key);
3100
+ const property = COMPUTED_CSS_PROPERTIES.get(key.toLowerCase());
3101
+ return property === undefined ? "" : values[property] ?? "";
2994
3102
  },
2995
3103
  getPropertyPriority: () => "",
2996
3104
  get cssText() {
2997
3105
  return "";
2998
3106
  },
2999
3107
  };
3108
+ if (reusable) computedStyleSnapshots.set(style, { custom, value: result });
3109
+ return result;
3000
3110
  }
3001
3111
 
3002
3112
  function formatConsoleValue(value) {