buninu-browser 0.5.1 → 0.7.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 (136) hide show
  1. package/README.md +13 -6
  2. package/index.js +10 -6
  3. package/lib/audio/pcm.js +11 -0
  4. package/lib/audio/pulse-client.js +7 -2
  5. package/lib/cdp-server.js +94 -46
  6. package/lib/happy-dom/document-base.js +29 -1
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/parser.js +2 -0
  9. package/lib/headless-shell.js +49 -9
  10. package/lib/layout/repaint.js +89 -0
  11. package/lib/media/mp4-keyframes.js +144 -0
  12. package/lib/media/video-keyframe.js +29 -1
  13. package/lib/media/webm-snapshots.js +282 -0
  14. package/lib/network/cookie-jar.js +18 -2
  15. package/lib/paint/display-list.js +4 -3
  16. package/lib/paint/frame.js +12 -5
  17. package/lib/render-tree/index.js +3 -0
  18. package/lib/renderer/animation-frames.js +76 -0
  19. package/lib/renderer/bindings.js +2 -0
  20. package/lib/renderer/compositor-channel.js +95 -10
  21. package/lib/renderer/compositor.js +54 -18
  22. package/lib/renderer/document-cookies.js +87 -0
  23. package/lib/renderer/font-loading.js +60 -16
  24. package/lib/renderer/host.js +48 -19
  25. package/lib/renderer/idle-callbacks.js +109 -0
  26. package/lib/renderer/main.js +5 -1
  27. package/lib/renderer/media-source.js +4 -0
  28. package/lib/renderer/page-realm.js +159 -49
  29. package/lib/renderer/page-renderer.js +567 -197
  30. package/lib/renderer/resource-elements.js +88 -0
  31. package/lib/renderer/script-rewrite.js +18 -1
  32. package/lib/renderer/seccomp.js +6 -4
  33. package/lib/renderer/timers.js +71 -0
  34. package/lib/renderer/video-channel.js +94 -0
  35. package/lib/renderer/video-mask.js +115 -0
  36. package/lib/renderer/video-presentation.js +304 -39
  37. package/lib/renderer/video-worker.js +46 -0
  38. package/lib/renderer/workers.js +7 -17
  39. package/lib/style/computed-style.js +257 -61
  40. package/package.json +1 -1
  41. package/test/animation-frames.test.js +67 -0
  42. package/test/canvas-style-snapshot.test.js +18 -0
  43. package/test/cdp-stream.test.js +96 -3
  44. package/test/cdp-viewers.test.js +43 -0
  45. package/test/compositor-channel-ownership.test.js +194 -0
  46. package/test/compositor.test.js +27 -1
  47. package/test/computed-style-binding.test.js +49 -0
  48. package/test/computed-style-connectivity.test.js +13 -0
  49. package/test/computed-style.test.js +86 -0
  50. package/test/cookie-sync.test.js +82 -0
  51. package/test/document-base-cache.test.js +103 -0
  52. package/test/document-cookies.test.js +117 -0
  53. package/test/duplicate-style-keys.test.js +11 -0
  54. package/test/fixtures/animation-frames.js +56 -0
  55. package/test/fixtures/canvas-style-snapshot.js +81 -0
  56. package/test/fixtures/computed-style-connectivity.js +101 -0
  57. package/test/fixtures/computed-style-lazy.js +135 -0
  58. package/test/fixtures/duplicate-style-keys.js +97 -0
  59. package/test/fixtures/fake-renderer.js +12 -0
  60. package/test/fixtures/font-definition-invalidation.js +97 -0
  61. package/test/fixtures/font-metrics-invalidation.js +87 -0
  62. package/test/fixtures/full-style-identity.js +36 -0
  63. package/test/fixtures/hidden-style-engine.js +260 -0
  64. package/test/fixtures/hidden-style-reentry.js +232 -0
  65. package/test/fixtures/hidden-style-renderer.js +203 -0
  66. package/test/fixtures/layout-repaint.js +69 -0
  67. package/test/fixtures/media-presentation-continuation.js +141 -0
  68. package/test/fixtures/media-presentation-refill.js +113 -0
  69. package/test/fixtures/media-probe.js +148 -0
  70. package/test/fixtures/media-refill-cancel.js +79 -0
  71. package/test/fixtures/media-repaint.js +48 -0
  72. package/test/fixtures/media-resource-reselection.js +94 -0
  73. package/test/fixtures/media-source-attachment.js +136 -0
  74. package/test/fixtures/media-source-no-decoder.js +17 -0
  75. package/test/fixtures/media-track-refill.js +132 -0
  76. package/test/fixtures/media-webm-seek.js +51 -0
  77. package/test/fixtures/mutation-observer.js +201 -0
  78. package/test/fixtures/observer-invalidation.js +105 -0
  79. package/test/fixtures/prepaint-scheduling.js +73 -0
  80. package/test/fixtures/resource-discovery.js +32 -0
  81. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  82. package/test/fixtures/style-propagation.js +26 -0
  83. package/test/fixtures/svg-node-reuse.js +83 -0
  84. package/test/fixtures/video-black-replay.js +140 -0
  85. package/test/fixtures/video-controls-invalidation.js +71 -0
  86. package/test/fixtures/viewport-bounds.js +148 -0
  87. package/test/font-loading.test.js +77 -0
  88. package/test/font-metrics-invalidation.test.js +60 -0
  89. package/test/full-style-identity.test.js +191 -0
  90. package/test/hidden-style-lazy.test.js +31 -0
  91. package/test/hidden-style-reentry.test.js +20 -0
  92. package/test/idle-callback.test.js +89 -0
  93. package/test/idle-scheduler.test.js +148 -0
  94. package/test/index.test.js +1 -0
  95. package/test/inline-style-cascade.test.js +63 -0
  96. package/test/layout-repaint-renderer.test.js +9 -0
  97. package/test/layout-repaint.test.js +222 -0
  98. package/test/media-presentation-continuation.test.js +14 -0
  99. package/test/media-presentation-refill.test.js +8 -0
  100. package/test/media-probe.test.js +16 -0
  101. package/test/media-repaint.test.js +8 -0
  102. package/test/media-source-attachment.test.js +34 -0
  103. package/test/media-track-refill.test.js +26 -0
  104. package/test/media-webm-seek.test.js +11 -0
  105. package/test/mp4-keyframes.test.js +36 -0
  106. package/test/mutation-observer.test.js +91 -0
  107. package/test/observer-invalidation.test.js +12 -0
  108. package/test/page-scripts.test.js +35 -0
  109. package/test/page-timers.test.js +67 -0
  110. package/test/paint-frame.test.js +18 -1
  111. package/test/pcm.test.js +63 -0
  112. package/test/prepaint-scheduling.test.js +13 -0
  113. package/test/pulse-client.test.js +165 -0
  114. package/test/raw-capture.test.js +34 -5
  115. package/test/renderer-host.test.js +51 -0
  116. package/test/resource-discovery.test.js +12 -0
  117. package/test/resource-elements.test.js +234 -0
  118. package/test/root-self-style.test.js +104 -0
  119. package/test/script-rewrite.test.js +35 -0
  120. package/test/seccomp.test.js +45 -0
  121. package/test/spare-renderer-scheduling.test.js +8 -0
  122. package/test/style-propagation.test.js +225 -0
  123. package/test/style-sheets.test.js +19 -0
  124. package/test/svg.test.js +45 -0
  125. package/test/timers.test.js +58 -0
  126. package/test/video-black-replay.test.js +34 -0
  127. package/test/video-capture.test.js +170 -0
  128. package/test/video-channel.test.js +253 -0
  129. package/test/video-continuation.test.js +57 -0
  130. package/test/video-controls-invalidation.test.js +12 -0
  131. package/test/video-keyframe.test.js +104 -4
  132. package/test/video-mask.test.js +94 -0
  133. package/test/video-presentation.test.js +239 -1
  134. package/test/video-timing-presentation.test.js +115 -0
  135. package/test/viewport-bounds.test.js +13 -0
  136. package/test/webm-snapshots.test.js +372 -0
@@ -14,6 +14,7 @@ const BLOCK_TAGS = new Set([
14
14
  "HEADER", "HR", "HTML", "LI", "MAIN", "NAV", "OL", "P", "PRE", "SECTION", "UL",
15
15
  ]);
16
16
  const NONE_TAGS = new Set(["HEAD", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE"]);
17
+ const HIDDEN_EAGER_TAGS = new Set(["HTML", "BODY", "BASE", "BASEFONT", "BGSOUND", "NOSCRIPT", "BR", "IMG", "CANVAS", "VIDEO", "AUDIO", "IFRAME", "OBJECT", "EMBED", "INPUT", "TEXTAREA", "SELECT"]);
17
18
  const INHERITED = new Set([
18
19
  "color", "color-scheme", "font-family", "font-size", "font-weight", "font-style", "font-stretch", "line-height", "list-style-type", "text-align", "white-space",
19
20
  "visibility", "overflow-wrap", "word-break", "border-spacing", "border-collapse", "pointer-events", "direction",
@@ -106,6 +107,14 @@ const PROPERTY_FIELDS = Object.freeze({
106
107
  top: ["inset"], right: ["inset"], bottom: ["inset"], left: ["inset"],
107
108
  });
108
109
  const BORDER_FIELDS = Object.freeze(["borderSpecifiedWidths", "borderStyles", "borderColors", "borderWidths"]);
110
+ // Inputs read from a parent even without an explicit `inherit` declaration.
111
+ // Display affects flex/grid item floats; text decorations propagate separately
112
+ // from the ordinary inherited properties. Root font changes also affect rem.
113
+ const CHILD_STYLE_INPUTS = [...new Set([
114
+ ...[...INHERITED].flatMap(name => PROPERTY_FIELDS[name]
115
+ ?? [INHERITED_FIELDS.get(name) ?? name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())]),
116
+ "display", "textDecorations",
117
+ ])];
109
118
 
110
119
  const INITIAL = Object.freeze({
111
120
  display: "inline",
@@ -323,9 +332,16 @@ let environment = DEFAULT_ENVIRONMENT;
323
332
 
324
333
  export class StyleEngine {
325
334
  #measureText;
335
+ #deferHidden;
336
+ #hiddenRoots = new WeakSet();
337
+ #hiddenAncestors = new WeakMap();
338
+ #ensured = null;
339
+ #queryContext = null;
340
+ #usesFontMetrics = false;
326
341
  #styles = new WeakMap();
327
342
  #customProperties = new WeakMap();
328
343
  #pseudoStyles = new WeakMap();
344
+ #parentDependencies = new WeakMap();
329
345
  #document = null;
330
346
  #sheetChanges = null;
331
347
  /** Elements computed by the last compute(), and whether it was incremental. */
@@ -338,10 +354,20 @@ export class StyleEngine {
338
354
  #parsedSheets = new Map();
339
355
  #parsedSheetsKey = "";
340
356
 
341
- constructor({ measureText = null } = {}) {
342
- this.#measureText = measureText;
357
+ // Renderers may defer invisible descendants; standalone callers remain eager.
358
+ constructor({ measureText = null, deferHidden = false } = {}) {
359
+ this.#deferHidden = deferHidden;
360
+ this.#measureText = measureText && ((text, style) => {
361
+ this.#usesFontMetrics = true;
362
+ return measureText(text, style);
363
+ });
343
364
  }
344
365
 
366
+ // Font-relative computed lengths (currently ch) depend on loaded typefaces.
367
+ // Keep this conservative across incremental passes, including var()/pseudo
368
+ // values and a root font-size which can change descendants' rem lengths.
369
+ get usesFontMetrics() { return this.#usesFontMetrics; }
370
+
345
371
  /**
346
372
  * Computes the document's styles. With `dirty` (elements whose subtree's
347
373
  * styles may have changed), only those subtrees are restyled and every
@@ -350,6 +376,7 @@ export class StyleEngine {
350
376
  * `restyled` afterwards counts the elements this call computed.
351
377
  */
352
378
  compute(document, styleSheets = [], viewport = {}, dirty = null, selfOnly = null) {
379
+ if (this.#document !== document) this.#usesFontMetrics = false;
353
380
  const previous = environment;
354
381
  environment = {
355
382
  viewportWidth: viewport.width ?? DEFAULT_ENVIRONMENT.viewportWidth,
@@ -359,9 +386,16 @@ export class StyleEngine {
359
386
  measureText: this.#measureText,
360
387
  };
361
388
  this.restyled = 0;
389
+ let queryRules = null, failed = false;
390
+ if (this.#deferHidden) {
391
+ this.#hiddenAncestors = new WeakMap();
392
+ this.#ensured = { styles: new WeakMap(), customProperties: new WeakMap(),
393
+ pseudoStyles: new WeakMap(), pending: new WeakSet() };
394
+ this.#queryContext = null;
395
+ }
362
396
  try {
363
397
  const before = this.#ruleCache.index;
364
- const rules = this.#rulesFor(styleSheets);
398
+ const rules = queryRules = this.#rulesFor(styleSheets);
365
399
  const root = document.documentElement;
366
400
  if (dirty !== null && rules !== before) {
367
401
  const affected = this.#elementsForSheetChanges(document);
@@ -371,11 +405,20 @@ export class StyleEngine {
371
405
  && Boolean(root) && this.#styles.has(root) && !dirty.has(root);
372
406
  this.incremental = incremental;
373
407
  if (!incremental) {
408
+ // Recompute every element into fresh maps so detached nodes lose their
409
+ // styles. On the same document, the prior pass can supply equal immutable
410
+ // identities for render/layout reuse. Keep this fallback local to the
411
+ // traversal, including when a callback starts another compute().
412
+ const previousMaps = this.#document === document ? {
413
+ styles: this.#styles, customProperties: this.#customProperties, pseudoStyles: this.#pseudoStyles,
414
+ } : null;
374
415
  this.#styles = new WeakMap();
375
416
  this.#customProperties = new WeakMap();
376
417
  this.#pseudoStyles = new WeakMap();
418
+ this.#parentDependencies = new WeakMap();
419
+ if (this.#deferHidden) this.#hiddenRoots = new WeakSet();
377
420
  this.#document = document;
378
- if (root) this.#visit(root, null, null, rules);
421
+ if (root) this.#visit(root, null, null, rules, undefined, previousMaps);
379
422
  return this;
380
423
  }
381
424
  // rem units resolve against the root's font size, which is unchanged.
@@ -388,13 +431,24 @@ export class StyleEngine {
388
431
  while (start !== root && !this.#styles.has(start.parentElement)) start = start.parentElement;
389
432
  starts.add(start);
390
433
  }
391
- if (starts.has(root) || selfOnly?.has(root)) {
434
+ if (starts.has(root)) {
392
435
  this.#visit(root, null, null, rules);
393
436
  return this;
394
437
  }
438
+ if (selfOnly?.has(root)) {
439
+ // A root-only selector/attribute change may leave its computed values
440
+ // unchanged. Check it like other self-only elements before cascading
441
+ // through the document; other dirty subtrees still need their pass.
442
+ // The root's own font-size rem units use the initial font size, not
443
+ // the previous generation's root size. #styleOne then sets the basis
444
+ // used by descendants, including a necessary full subtree restyle.
445
+ environment = { ...environment, rootFontSize: DEFAULT_ENVIRONMENT.rootFontSize };
446
+ if (this.#restyleSelf(root, rules)) return this;
447
+ }
395
448
  const topmost = topmostConnected(starts, root);
396
449
  for (const start of topmost) {
397
450
  const parent = start.parentElement;
451
+ if (this.#deferHidden && this.#hiddenBoundary(parent)) continue;
398
452
  const ancestors = this.#ancestorsOf(start);
399
453
  this.#visit(start, this.#styles.get(parent), this.#customProperties.get(parent) ?? EMPTY_MAP, rules, ancestors);
400
454
  }
@@ -407,7 +461,14 @@ export class StyleEngine {
407
461
  for (let node = element; node && !inside; node = node.parentElement) inside = covered.has(node);
408
462
  if (!inside) this.#restyleSelf(element, rules);
409
463
  }
464
+ } catch (error) {
465
+ failed = true;
466
+ throw error;
410
467
  } finally {
468
+ if (this.#deferHidden && !failed && this.#document === document) {
469
+ this.#queryContext = { rules: queryRules, environment: { ...environment,
470
+ rootFontSize: this.#styles.get(document.documentElement)?.fontSize ?? DEFAULT_ENVIRONMENT.rootFontSize } };
471
+ }
411
472
  environment = previous;
412
473
  }
413
474
  return this;
@@ -502,29 +563,106 @@ export class StyleEngine {
502
563
  }
503
564
 
504
565
  get(element) {
505
- return this.#styles.get(element) ?? INITIAL;
566
+ return (this.#ensureHidden(element)?.styles ?? this.#styles).get(element) ?? INITIAL;
506
567
  }
507
568
 
508
569
  /** An element's custom properties (--*), name to value text, inherited ones included. */
509
570
  customPropertiesOf(element) {
510
- return this.#customProperties.get(element) ?? EMPTY_MAP;
571
+ return (this.#ensureHidden(element)?.customProperties ?? this.#customProperties).get(element) ?? EMPTY_MAP;
511
572
  }
512
573
 
513
574
  /** The computed style of an element's ::before/::after box, or null when it generates none. */
514
575
  getPseudo(element, pseudo) {
515
- return this.#pseudoStyles.get(element)?.[pseudo] ?? null;
576
+ return (this.#ensureHidden(element)?.pseudoStyles ?? this.#pseudoStyles).get(element)?.[pseudo] ?? null;
577
+ }
578
+
579
+ // Only descendants are deferred: the boundary itself must remain styled so
580
+ // its display/inherited/custom values can be observed or reveal its children.
581
+ #canDefer(element, style) {
582
+ return style.display === "none" && element.namespaceURI === "http://www.w3.org/1999/xhtml"
583
+ && element !== this.#document.documentElement && element !== this.#document.body
584
+ && !NONE_TAGS.has(element.tagName)
585
+ && !HIDDEN_EAGER_TAGS.has(element.tagName);
586
+ }
587
+
588
+ // Inclusive outermost hidden boundary. Nested old boundaries can have stale
589
+ // base styles after their ancestor hides, so queries begin at the outermost.
590
+ // Cache ancestor paths per compute pass;
591
+ // changing a boundary clears them before any later traversal/query can use it.
592
+ #hiddenBoundary(element) {
593
+ const path = [];
594
+ let node = element;
595
+ while (node && !this.#hiddenAncestors.has(node)) { path.push(node); node = node.parentElement; }
596
+ let boundary = node ? this.#hiddenAncestors.get(node) : null;
597
+ for (let index = path.length - 1; index >= 0; index--) {
598
+ const current = path[index];
599
+ if (!boundary && this.#hiddenRoots.has(current)) boundary = current;
600
+ this.#hiddenAncestors.set(current, boundary);
601
+ }
602
+ return boundary;
603
+ }
604
+
605
+ #ensureHidden(element) {
606
+ if (!this.#deferHidden || !this.#queryContext || !element?.isConnected
607
+ || element.ownerDocument !== this.#document) return null;
608
+ const boundary = this.#hiddenBoundary(element.parentElement);
609
+ if (!boundary) return null;
610
+ const target = this.#ensured, context = this.#queryContext;
611
+ // Font measurement may synchronously read styles. Do not restart an
612
+ // unfinished chain. During pseudo computation the principal style is
613
+ // already available, while pseudo reads still see the prior generation.
614
+ if (target.pending.has(element)) return target.styles.has(element)
615
+ ? { styles: target.styles, customProperties: target.customProperties, pseudoStyles: this.#pseudoStyles }
616
+ : null;
617
+ if (target.styles.has(element)) return target;
618
+ const chain = [];
619
+ let parent = element;
620
+ while (parent && parent !== boundary && !target.styles.has(parent)) {
621
+ if (target.pending.has(parent)) return null;
622
+ chain.push(parent); parent = parent.parentElement;
623
+ }
624
+ if (!parent || target.pending.has(parent)) return null;
625
+ const parentMaps = parent === boundary ? { styles: this.#styles, customProperties: this.#customProperties } : target;
626
+ let style = parentMaps.styles.get(parent), custom = parentMaps.customProperties.get(parent) ?? EMPTY_MAP;
627
+ for (const node of chain) target.pending.add(node);
628
+ const previous = environment;
629
+ environment = context.environment;
630
+ try {
631
+ const ancestors = this.#ancestorsOf(chain.at(-1));
632
+ const previousMaps = { styles: this.#styles, customProperties: this.#customProperties, pseudoStyles: this.#pseudoStyles };
633
+ for (let index = chain.length - 1; index >= 0; index--) {
634
+ const node = chain[index], keys = elementKeys(node);
635
+ try {
636
+ const result = this.#styleOne(node, style, custom, context.rules, keys, ancestors, previousMaps, target);
637
+ style = result.style; custom = result.customProperties;
638
+ } catch (error) {
639
+ // A pseudo can fail after principal values were stored. Retain only
640
+ // complete ancestors; the failed node must be recomputed on retry.
641
+ target.styles.delete(node); target.customProperties.delete(node); target.pseudoStyles.delete(node);
642
+ throw error;
643
+ } finally { target.pending.delete(node); }
644
+ ancestors.push(keys);
645
+ }
646
+ } finally {
647
+ for (const node of chain) target.pending.delete(node);
648
+ environment = previous;
649
+ }
650
+ return target;
516
651
  }
517
652
 
518
653
  /** Computes and stores one element's style and ::before/::after styles. */
519
- #styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors) {
520
- const previousStyle = this.#styles.get(element);
521
- const previousCustom = this.#customProperties.get(element);
522
- const previousPseudo = this.#pseudoStyles.get(element);
654
+ #styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors, previousMaps = null, target = null) {
655
+ const styles = target?.styles ?? this.#styles, customMap = target?.customProperties ?? this.#customProperties;
656
+ const pseudoMap = target?.pseudoStyles ?? this.#pseudoStyles;
657
+ const previousStyle = styles.get(element) ?? previousMaps?.styles.get(element);
658
+ const previousCustom = customMap.get(element) ?? previousMaps?.customProperties.get(element);
659
+ const previousPseudo = pseudoMap.get(element) ?? previousMaps?.pseudoStyles.get(element);
523
660
  // Inline style takes part in the cascade before custom properties are
524
661
  // resolved: style="--x: ..." must win over stylesheet values of --x.
525
662
  const declarations = withInlineStyle(element, cascadeDeclarations(element, rules, null, keys, ancestors));
526
663
  let customProperties = withCustomProperties(parentCustomProperties ?? EMPTY_MAP, declarations);
527
- let style = computeElementStyle(element, parentStyle, declarations, customProperties);
664
+ const dependencies = { inheritsParent: false };
665
+ let style = computeElementStyle(element, parentStyle, declarations, customProperties, dependencies, true);
528
666
  // A descendant may be visited because an ancestor's selector state
529
667
  // changed even though its final computed values did not. Preserve the
530
668
  // immutable identities so retained render and layout subtrees survive.
@@ -532,36 +670,49 @@ export class StyleEngine {
532
670
  if (sameCustom(previousCustom, customProperties)) customProperties = previousCustom;
533
671
  // rem units resolve against the root element's computed font size.
534
672
  if (!parentStyle) environment = { ...environment, rootFontSize: style.fontSize };
535
- this.#styles.set(element, style);
536
- this.#customProperties.set(element, customProperties);
673
+ styles.set(element, style);
674
+ customMap.set(element, customProperties);
537
675
  let pseudoStyles = {};
538
676
  for (const pseudo of ["before", "after"]) {
539
677
  const pseudoDeclarations = cascadeDeclarations(element, rules, pseudo, keys, ancestors);
540
678
  if (!pseudoDeclarations.has("content")) continue;
541
679
  const pseudoCustom = withCustomProperties(customProperties, pseudoDeclarations);
542
- const pseudoStyle = computeElementStyle(pseudoElement(pseudo), style, pseudoDeclarations, pseudoCustom);
680
+ const pseudoStyle = computeElementStyle(pseudoElement(pseudo), style, pseudoDeclarations, pseudoCustom, dependencies);
543
681
  // `content: none | normal` generates no box (CSS Pseudo 4 §2.3).
544
682
  if (pseudoStyle.content != null && pseudoStyle.display !== "none") pseudoStyles[pseudo] = pseudoStyle;
545
683
  }
546
684
  if (sameValue(previousPseudo ?? null, (pseudoStyles.before || pseudoStyles.after) ? pseudoStyles : null)) {
547
685
  pseudoStyles = previousPseudo ?? {};
548
686
  }
549
- if (pseudoStyles.before || pseudoStyles.after) this.#pseudoStyles.set(element, pseudoStyles);
550
- else this.#pseudoStyles.delete(element);
551
- this.restyled++;
687
+ if (pseudoStyles.before || pseudoStyles.after) pseudoMap.set(element, pseudoStyles);
688
+ else pseudoMap.delete(element);
689
+ // Dependencies can change without changing computed values, for example
690
+ // width:100px -> width:inherit while the parent is already 100px wide.
691
+ if (!target) {
692
+ this.#parentDependencies.set(element, dependencies.inheritsParent);
693
+ if (this.#deferHidden) {
694
+ const hidden = this.#canDefer(element, style);
695
+ if (hidden !== this.#hiddenRoots.has(element)) {
696
+ if (hidden) this.#hiddenRoots.add(element); else this.#hiddenRoots.delete(element);
697
+ this.#hiddenAncestors = new WeakMap();
698
+ }
699
+ }
700
+ this.restyled++;
701
+ }
552
702
  return { style, customProperties };
553
703
  }
554
704
 
555
- #visit(element, parentStyle, parentCustomProperties, rules, ancestors = new AncestorFilter()) {
705
+ #visit(element, parentStyle, parentCustomProperties, rules, ancestors = new AncestorFilter(), previousMaps = null) {
556
706
  const keys = elementKeys(element);
557
- const { style, customProperties } = this.#styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors);
558
- this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
707
+ const { style, customProperties } = this.#styleOne(element, parentStyle, parentCustomProperties, rules, keys, ancestors, previousMaps);
708
+ this.#visitChildren(element, keys, style, customProperties, rules, ancestors, previousMaps);
559
709
  }
560
710
 
561
- #visitChildren(element, keys, style, customProperties, rules, ancestors) {
711
+ #visitChildren(element, keys, style, customProperties, rules, ancestors, previousMaps = null) {
712
+ if (this.#deferHidden && this.#hiddenRoots.has(element)) return;
562
713
  // The element is an ancestor of everything below it.
563
714
  const own = ancestors.push(keys);
564
- for (const child of element.children) this.#visit(child, style, customProperties, rules, ancestors);
715
+ for (const child of element.children) this.#visit(child, style, customProperties, rules, ancestors, previousMaps);
565
716
  ancestors.pop(own);
566
717
  }
567
718
 
@@ -573,32 +724,59 @@ export class StyleEngine {
573
724
  }
574
725
 
575
726
  /**
576
- * Restyles one element; only when its style (or ::before/::after, or the
577
- * custom properties it passes down) changed does its subtree follow.
727
+ * Restyles one element and propagates changed parent inputs. Returns true
728
+ * only when every descendant was visited, so a root-only update cannot
729
+ * swallow independently dirty subtrees after a partial propagation.
578
730
  */
579
- #restyleSelf(element, rules) {
731
+ #restyleSelf(element, rules, ancestors = this.#ancestorsOf(element)) {
580
732
  const parent = element.parentElement;
733
+ if (this.#deferHidden && this.#hiddenBoundary(parent)) return false;
581
734
  const before = { style: this.#styles.get(element), pseudo: this.#pseudoStyles.get(element), custom: this.#customProperties.get(element) };
582
735
  const keys = elementKeys(element);
583
- const ancestors = this.#ancestorsOf(element);
584
736
  const { style, customProperties } = this.#styleOne(element, this.#styles.get(parent), this.#customProperties.get(parent) ?? EMPTY_MAP, rules, keys, ancestors);
737
+ if (this.#deferHidden && this.#hiddenRoots.has(element)) return true;
585
738
  const pseudo = this.#pseudoStyles.get(element);
586
739
  if (sameValue(before.style, style) && sameValue(before.pseudo ?? null, pseudo ?? null) && sameCustom(before.custom, customProperties)) {
587
740
  // Unchanged: keep the previous objects so later comparisons stay cheap.
588
741
  this.#styles.set(element, before.style);
589
- return;
742
+ return false;
590
743
  }
591
- this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
744
+ if (!sameChildStyleInputs(before.style, style) || !sameCustom(before.custom, customProperties)) {
745
+ this.#visitChildren(element, keys, style, customProperties, rules, ancestors);
746
+ return true;
747
+ }
748
+ // The parent's ordinary inherited inputs did not change. Only immediate
749
+ // children with explicit inheritance can observe its other properties;
750
+ // an unchanged child also shields its descendants from those changes.
751
+ const own = ancestors.push(keys);
752
+ try {
753
+ for (const child of element.children) {
754
+ if (!this.#styles.has(child) || !this.#parentDependencies.has(child)) {
755
+ this.#visit(child, style, customProperties, rules, ancestors);
756
+ } else if (before.style !== style && this.#parentDependencies.get(child)) {
757
+ this.#restyleSelf(child, rules, ancestors);
758
+ }
759
+ }
760
+ } finally { ancestors.pop(own); }
761
+ return false;
592
762
  }
593
763
  }
594
764
 
765
+ function sameChildStyleInputs(before, after) {
766
+ return Boolean(before && after) && CHILD_STYLE_INPUTS.every(field => sameValue(before[field], after[field]));
767
+ }
768
+
595
769
  /** Structural equality of computed style values (frozen objects and arrays). */
596
770
  function sameValue(left, right) {
597
771
  if (left === right) return true;
598
772
  if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
599
773
  const keys = Object.keys(left);
600
774
  if (keys.length !== Object.keys(right).length) return false;
601
- return keys.every((key) => sameValue(left[key], right[key]));
775
+ for (let index = 0; index < keys.length; index++) {
776
+ const key = keys[index], a = left[key], b = right[key];
777
+ if (a !== b && !sameValue(a, b)) return false;
778
+ }
779
+ return true;
602
780
  }
603
781
 
604
782
  function sameCustom(left, right) {
@@ -684,7 +862,7 @@ function withCustomProperties(inherited, declarations) {
684
862
  return result;
685
863
  }
686
864
 
687
- export function computeElementStyle(element, parentStyle = null, authorDeclarations = null, customProperties = new Map()) {
865
+ export function computeElementStyle(element, parentStyle = null, authorDeclarations = null, customProperties = new Map(), dependencies = null, inlineCascaded = false) {
688
866
  const values = { ...INITIAL };
689
867
  if (parentStyle) {
690
868
  for (const property of INHERITED) {
@@ -742,14 +920,18 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
742
920
  declarations.set(declaration.name, { ...declaration, specificity: -1, order: -1, layer: -Infinity });
743
921
  }
744
922
  }
745
- for (const declaration of parseDeclarations(element.getAttribute("style") ?? "").values()) {
923
+ // StyleEngine already merged inline declarations before resolving custom
924
+ // properties. Direct callers still need the ordinary inline cascade here.
925
+ for (const declaration of inlineCascaded ? [] : parseDeclarations(element.getAttribute("style") ?? "").values()) {
746
926
  const current = declarations.get(declaration.name);
747
927
  if (!current?.important || declaration.important) {
748
928
  declarations.set(declaration.name, { ...declaration, specificity: 1_000_000, order: Infinity });
749
929
  }
750
930
  }
751
931
  const reset = declarations.get("all");
752
- if (reset && CSS_WIDE_KEYWORDS.has(resolveVariables(reset.value, customProperties))) {
932
+ const resetValue = reset && resolveVariables(reset.value, customProperties);
933
+ if (reset && CSS_WIDE_KEYWORDS.has(resetValue)) {
934
+ if (dependencies && resetValue === "inherit") dependencies.inheritsParent = true;
753
935
  // Expand before resolving font prerequisites. Competing declarations
754
936
  // keep their normal importance, specificity and source-order priority.
755
937
  for (const name of ALL_PROPERTIES) {
@@ -763,17 +945,18 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
763
945
  const font = declarations.get("font");
764
946
  if (font && (!fontSize || compareDeclarations(font, fontSize) > 0)) {
765
947
  const fontValue = resolveVariables(font.value, customProperties);
948
+ if (dependencies && fontValue === "inherit") dependencies.inheritsParent = true;
766
949
  const parsed = parseFontShorthand(fontValue);
767
950
  if (CSS_WIDE_KEYWORDS.has(fontValue)) setProperty(values, "font-size", fontValue, parentStyle);
768
951
  else if (parsed) setProperty(values, "font-size", parsed.size, parentStyle);
769
952
  } else if (fontSize) {
770
- applyDeclaration(values, fontSize, customProperties, parentStyle);
953
+ applyDeclaration(values, fontSize, customProperties, parentStyle, dependencies);
771
954
  }
772
955
  // light-dark() depends on the used scheme regardless of declaration order.
773
956
  const colorScheme = declarations.get("color-scheme");
774
- if (colorScheme) applyDeclaration(values, colorScheme, customProperties, parentStyle);
957
+ if (colorScheme) applyDeclaration(values, colorScheme, customProperties, parentStyle, dependencies);
775
958
  const directionDeclaration = declarations.get("direction");
776
- if (directionDeclaration) applyDeclaration(values, directionDeclaration, customProperties, parentStyle);
959
+ if (directionDeclaration) applyDeclaration(values, directionDeclaration, customProperties, parentStyle, dependencies);
777
960
  // Apply in cascade order so a later shorthand overrides earlier longhands and vice versa.
778
961
  const ordered = [...declarations.values()].sort(compareDeclarations);
779
962
  // Font-relative lengths such as ch use this element's final font, even
@@ -781,6 +964,7 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
781
964
  for (const declaration of ordered) {
782
965
  if (declaration.name === "font") {
783
966
  const fontValue = resolveVariables(declaration.value, customProperties);
967
+ if (dependencies && fontValue === "inherit") dependencies.inheritsParent = true;
784
968
  const parsed = parseFontShorthand(fontValue);
785
969
  if (CSS_WIDE_KEYWORDS.has(fontValue)) {
786
970
  setProperty(values, "font-family", fontValue, parentStyle);
@@ -795,13 +979,13 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
795
979
  setProperty(values, "font-stretch", parsed.stretch ?? "normal", parentStyle);
796
980
  }
797
981
  } else if (["font-family", "font-weight", "font-style", "font-stretch", "font-width"].includes(declaration.name)) {
798
- applyDeclaration(values, declaration, customProperties, parentStyle);
982
+ applyDeclaration(values, declaration, customProperties, parentStyle, dependencies);
799
983
  }
800
984
  }
801
985
  for (const declaration of ordered) {
802
986
  if (declaration.name.startsWith("--") || ["font-size", "font-family", "font-weight", "font-style", "font-stretch", "font-width", "color-scheme", "direction"].includes(declaration.name)) continue;
803
987
  // The shorthand's size was settled above; its other parts apply in order.
804
- applyDeclaration(values, declaration.name === "font" ? { ...declaration, name: "font-parts" } : declaration, customProperties, parentStyle);
988
+ applyDeclaration(values, declaration.name === "font" ? { ...declaration, name: "font-parts" } : declaration, customProperties, parentStyle, dependencies);
805
989
  }
806
990
  // Floats (approximation): laid out as atomic inlines in the line, left floats
807
991
  // in order and right floats at the line's end. Absolutely positioned boxes
@@ -817,8 +1001,10 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
817
1001
  values.borderWidths = Object.freeze(values.borderSpecifiedWidths.map((width, index) =>
818
1002
  ["none", "hidden"].includes(values.borderStyles[index]) ? 0 : width));
819
1003
  values.borderColors = Object.freeze(values.borderColors.map((color) => color ?? values.color));
820
- values.borderStyles = Object.freeze([...values.borderStyles]);
821
- values.borderSpecifiedWidths = Object.freeze([...values.borderSpecifiedWidths]);
1004
+ // Initial/inherited arrays are already immutable. Setters use copy-on-write;
1005
+ // retain those arrays, but still isolate any mutable caller-owned values.
1006
+ values.borderStyles = Object.isFrozen(values.borderStyles) ? values.borderStyles : Object.freeze([...values.borderStyles]);
1007
+ values.borderSpecifiedWidths = Object.isFrozen(values.borderSpecifiedWidths) ? values.borderSpecifiedWidths : Object.freeze([...values.borderSpecifiedWidths]);
822
1008
  // A unitless line-height inherits as a factor of each element's own font size.
823
1009
  if (values.lineHeightFactor != null) values.lineHeight = values.lineHeightFactor * values.fontSize;
824
1010
  if (values.color === "rgba(0, 0, 0, 0)" && values.backgroundClip === "text" && values.backgroundImage) {
@@ -838,7 +1024,7 @@ export function computeElementStyle(element, parentStyle = null, authorDeclarati
838
1024
  values.textDecorations = decorations;
839
1025
  if (control === "textarea" || control === "text") applyControlSize(element, control, values);
840
1026
  values.margin = Object.freeze(values.margin);
841
- values.inset = Object.freeze([...values.inset]);
1027
+ values.inset = Object.isFrozen(values.inset) ? values.inset : Object.freeze([...values.inset]);
842
1028
  values.padding = Object.freeze(values.padding);
843
1029
  return Object.freeze(values);
844
1030
  }
@@ -1298,9 +1484,11 @@ const reportedDeclarationErrors = new Set();
1298
1484
  * Applies one cascaded declaration. A declaration the engine cannot handle is
1299
1485
  * dropped, as an invalid declaration would be, instead of failing the page.
1300
1486
  */
1301
- function applyDeclaration(style, declaration, customProperties, parentStyle) {
1487
+ function applyDeclaration(style, declaration, customProperties, parentStyle, dependencies = null) {
1302
1488
  try {
1303
- setProperty(style, declaration.name, resolveVariables(declaration.value, customProperties), parentStyle);
1489
+ const value = resolveVariables(declaration.value, customProperties);
1490
+ if (dependencies && value === "inherit") dependencies.inheritsParent = true;
1491
+ setProperty(style, declaration.name, value, parentStyle);
1304
1492
  } catch (error) {
1305
1493
  const key = `${declaration.name}: ${declaration.value}`;
1306
1494
  if (reportedDeclarationErrors.size < 100 && !reportedDeclarationErrors.has(key)) {
@@ -2130,7 +2318,11 @@ export function matchSelectors(root, selectors, compiledSelectors = new Map()) {
2130
2318
  function elementKeys(element) {
2131
2319
  const id = element.getAttribute?.("id") || null;
2132
2320
  const className = element.getAttribute?.("class");
2133
- const classes = className ? [...new Set(className.split(/\s+/))].filter(Boolean) : [];
2321
+ // Candidate stamps deduplicate rules/selectors, and ancestor counts balance
2322
+ // repeated keys. Deduplicate long lists to bound repeated bucket walks while
2323
+ // avoiding a Set allocation for the usual short class attribute.
2324
+ let classes = className ? className.split(/\s+/).filter(Boolean) : [];
2325
+ if (classes.length > 16) classes = [...new Set(classes)];
2134
2326
  const attrs = [];
2135
2327
  for (const attribute of element.attributes ?? []) attrs.push(String(attribute.name).toLowerCase());
2136
2328
  const tag = element.localName ?? element.tagName;
@@ -2509,23 +2701,27 @@ function copyProperty(style, name, source) {
2509
2701
  }
2510
2702
  }
2511
2703
 
2704
+ // Most inheritance asks for one value (often line-height). Do not serialize
2705
+ // unrelated font families and numeric properties for every element.
2512
2706
  function cssValue(style, name) {
2513
- return ({
2514
- "background-color": style.backgroundColor,
2515
- "font-family": style.fontFamily.map((family) => JSON.stringify(family)).join(", "),
2516
- "font-size": `${style.fontSize}px`,
2517
- "font-weight": String(style.fontWeight),
2518
- "font-style": style.fontStyle,
2519
- "font-stretch": `${style.fontStretch}%`,
2520
- "font-width": `${style.fontStretch}%`,
2521
- "line-height": style.lineHeightFactor != null ? String(style.lineHeightFactor)
2522
- : style.lineHeight == null ? "normal" : `${style.lineHeight}px`,
2523
- "list-style-type": style.listStyleType,
2524
- "pointer-events": style.pointerEvents,
2525
- "user-select": style.userSelect,
2526
- "text-align": style.textAlign,
2527
- "white-space": style.whiteSpace,
2528
- })[name] ?? style[name];
2707
+ let value;
2708
+ switch (name) {
2709
+ case "background-color": value = style.backgroundColor; break;
2710
+ case "font-family": value = style.fontFamily.map((family) => JSON.stringify(family)).join(", "); break;
2711
+ case "font-size": value = `${style.fontSize}px`; break;
2712
+ case "font-weight": value = String(style.fontWeight); break;
2713
+ case "font-style": value = style.fontStyle; break;
2714
+ case "font-stretch":
2715
+ case "font-width": value = `${style.fontStretch}%`; break;
2716
+ case "line-height": value = style.lineHeightFactor != null ? String(style.lineHeightFactor)
2717
+ : style.lineHeight == null ? "normal" : `${style.lineHeight}px`; break;
2718
+ case "list-style-type": value = style.listStyleType; break;
2719
+ case "pointer-events": value = style.pointerEvents; break;
2720
+ case "user-select": value = style.userSelect; break;
2721
+ case "text-align": value = style.textAlign; break;
2722
+ case "white-space": value = style.whiteSpace; break;
2723
+ }
2724
+ return value ?? style[name];
2529
2725
  }
2530
2726
 
2531
2727
  function pixelLength(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-browser",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Buninu Browser is a browser engine written in JavaScript and WebAssembly for Bun.",
5
5
  "keywords": [
6
6
  "buninu",
@@ -0,0 +1,67 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createAnimationFrames, supportsFrameCheckpoint } from "../lib/renderer/animation-frames.js";
3
+
4
+ // Bun.version hides the suffix of unsafe 1.4.3 canaries. Require the later
5
+ // 1.4.4 branch and retain timers for unknown prerelease channels.
6
+ test("animation batching requires a microtask-only Bun checkpoint", () => {
7
+ for (const version of ["1.4.2", "1.4.3", "1.4.3-canary.1", "1.4.4-dev.1", "1.4.4+local", "v1.4.4", "unknown", null]) {
8
+ expect(supportsFrameCheckpoint(version)).toBe(false);
9
+ }
10
+ for (const version of ["1.4.4", "1.4.4-canary", "1.4.4-canary.1", "1.5.0"]) expect(supportsFrameCheckpoint(version)).toBe(true);
11
+ });
12
+
13
+ const frameTest = supportsFrameCheckpoint() ? test : test.skip;
14
+ frameTest("frame snapshots preserve timestamps, checkpoints, cancellation and disposal", () => {
15
+ let now = 0, nextWake = 0;
16
+ const pending = new Map(), events = [], timestamps = [], errors = [];
17
+ const timers = {
18
+ schedule(callback, delay, repeat, timerTask) {
19
+ expect([repeat, timerTask]).toEqual([false, false]);
20
+ pending.set(++nextWake, { callback, delay });
21
+ return nextWake;
22
+ },
23
+ cancel(id) { pending.delete(id); },
24
+ dispose() { pending.clear(); },
25
+ };
26
+ const fire = () => {
27
+ const [id, entry] = pending.entries().next().value;
28
+ pending.delete(id); now += entry.delay; entry.callback();
29
+ };
30
+ const frames = createAnimationFrames({ now: () => now, frameTimers: timers,
31
+ invoke(callback, time) { try { callback(time); } catch (error) { errors.push(error.message); } },
32
+ });
33
+ let cancelled;
34
+ frames.request(time => {
35
+ timestamps.push(time); events.push("first"); now += 40;
36
+ Promise.resolve().then(() => events.push("microtask-one")).then(() => {
37
+ events.push("microtask-two"); frames.cancel(cancelled);
38
+ frames.request(time => {
39
+ events.push("nested"); timestamps.push(time);
40
+ Promise.resolve().then(() => frames.dispose());
41
+ });
42
+ frames.request(() => events.push("disposed"));
43
+ });
44
+ });
45
+ cancelled = frames.request(() => events.push("cancelled"));
46
+ frames.request(time => { timestamps.push(time); throw new Error("expected"); });
47
+ frames.request(time => { events.push("last"); timestamps.push(time); });
48
+ expect(pending.size).toBe(1); fire();
49
+ expect(events).toEqual(["first", "microtask-one", "microtask-two", "last"]);
50
+ expect(timestamps).toEqual([16, 16, 16]);
51
+ expect(errors).toEqual(["expected"]);
52
+ expect(pending.size).toBe(1); fire();
53
+ expect(events.at(-1)).toBe("nested");
54
+ expect(timestamps.at(-1)).toBe(56);
55
+ expect(pending.size).toBe(0);
56
+ });
57
+
58
+ test("actual SES frame callbacks match the browser callback order and lifecycle", async () => {
59
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/animation-frames.js", import.meta.url).pathname], {
60
+ stdout: "pipe", stderr: "pipe",
61
+ });
62
+ const [out, error, code] = await Promise.all([
63
+ new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
64
+ ]);
65
+ expect({ code, error }).toEqual({ code: 0, error: "" });
66
+ expect(out).toContain("animation frame ordering and lifecycle passed");
67
+ }, 15000);