buninu-browser 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cdp-server.js CHANGED
@@ -509,7 +509,9 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
509
509
  const target = getSessionTarget(state, sessionId);
510
510
  target.screencast = { ...params };
511
511
  target.screencastEmit = emit;
512
- await emitScreencastFrame(ctx, state, target, emit);
512
+ // As in Chrome, the reply does not wait for the first frame (nor the
513
+ // renderer, which may still be starting); frames follow as events.
514
+ void pushScreencastFrame(ctx, state, target);
513
515
  return {};
514
516
  }
515
517
  case "Page.screencastFrameAck":
@@ -0,0 +1,83 @@
1
+ import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
2
+ import QuerySelector from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts";
3
+ import SelectorCombinatorEnum from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorCombinatorEnum.ts";
4
+ import SelectorParser from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts";
5
+
6
+ /**
7
+ * Selector matching for the style engine, which matches tens of thousands of
8
+ * stylesheet selectors against every element. It gives the answers of Happy
9
+ * DOM's Element.matches() (same parser, same per-compound matching), without
10
+ * what that pays on every call: re-validating and looking up the selector
11
+ * string, and recording a cached result on the element and the elements it
12
+ * visited. The style engine keeps its own results.
13
+ */
14
+
15
+ // Element.matches() rejects these before parsing (Happy DOM's QuerySelector).
16
+ const INVALID_SELECTOR = /^[.#\[]?\d|[.#]$/;
17
+
18
+ /**
19
+ * A selector compiled for matchesCompiled(), or null when it is invalid.
20
+ * `element` is any element of the document (it provides the window). The
21
+ * result does not depend on the document and may be kept with the selector.
22
+ * Returns undefined for objects that are not Happy DOM elements.
23
+ */
24
+ export function compileSelector(element, selector) {
25
+ const window = element?.[PropertySymbol.window];
26
+ if (!window) return undefined;
27
+ selector = String(selector);
28
+ if (INVALID_SELECTOR.test(selector)) return null;
29
+ try {
30
+ const groups = new SelectorParser({ window, scope: element, globalMatchFunction: QuerySelector.globalMatchFunction })
31
+ .getSelectorGroups(selector);
32
+ // Matching walks from the subject (the last compound) leftwards.
33
+ return groups.map((items) => items.slice().reverse());
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /** Whether `element` matches a compiled selector, as element.matches(selector) would. */
40
+ export function matchesCompiled(element, compiled) {
41
+ for (const items of compiled) {
42
+ if (matchItems(element, element, items, 0, null)) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ /** Happy DOM's QuerySelector.matchSelector, reduced to a yes or no. */
48
+ function matchItems(scope, element, items, index, previous) {
49
+ const item = items[index];
50
+ if (!item) return false;
51
+ if (item.match(scope, element)) {
52
+ if (index === items.length - 1) return true;
53
+ switch (item.combinator) {
54
+ case SelectorCombinatorEnum.adjacentSibling: {
55
+ const sibling = element.previousElementSibling;
56
+ if (sibling && matchItems(scope, sibling, items, index + 1, item)) return true;
57
+ break;
58
+ }
59
+ case SelectorCombinatorEnum.none:
60
+ case SelectorCombinatorEnum.child:
61
+ case SelectorCombinatorEnum.descendant: {
62
+ const parent = element.parentNode;
63
+ if (parent && parent !== element[PropertySymbol.ownerDocument] && matchItems(scope, parent, items, index + 1, item)) return true;
64
+ break;
65
+ }
66
+ case SelectorCombinatorEnum.subsequentSibling: {
67
+ const parent = element.parentNode;
68
+ if (parent && parent !== element[PropertySymbol.ownerDocument]) {
69
+ const siblings = parent[PropertySymbol.elementArray];
70
+ for (let i = siblings.indexOf(element) - 1; i >= 0; i--) {
71
+ if (matchItems(scope, siblings[i], items, index + 1, item)) return true;
72
+ }
73
+ }
74
+ break;
75
+ }
76
+ }
77
+ }
78
+ if (previous?.combinator === SelectorCombinatorEnum.none || previous?.combinator === SelectorCombinatorEnum.descendant) {
79
+ const parent = element.parentNode;
80
+ if (parent && parent !== element[PropertySymbol.ownerDocument]) return matchItems(scope, parent, items, index, previous);
81
+ }
82
+ return false;
83
+ }
@@ -37,15 +37,23 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
37
37
  const mobile = args.includes(MOBILE);
38
38
  if (mobile) console.error(`buninu-browser: ${MOBILE}: mobile user agent and touch media features; user agent overrides are ignored`);
39
39
 
40
- const browser = await createBrowser({ allowHostJs, mobile });
41
- const layerStatus = (layer) => layer?.installed ? "on" : `off (${layer?.reason})`;
42
- console.error(`buninu-browser: renderer sandbox: seccomp ${layerStatus(browser.sandbox?.seccomp)}, `
43
- + `ses ${layerStatus(browser.sandbox?.ses)}`);
44
-
40
+ // The endpoint opens while the first renderer starts: a client's first
41
+ // navigation fetches its document meanwhile.
42
+ const browser = await createBrowser({ allowHostJs, mobile, waitForRenderer: false });
45
43
  const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
46
44
  const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
47
45
  const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
48
46
  console.error(`DevTools listening on ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
47
+ try {
48
+ await browser.ready;
49
+ } catch (error) {
50
+ server.stop(true);
51
+ browser.close();
52
+ throw error;
53
+ }
54
+ const layerStatus = (layer) => layer?.installed ? "on" : `off (${layer?.reason})`;
55
+ console.error(`buninu-browser: renderer sandbox: seccomp ${layerStatus(browser.sandbox?.seccomp)}, `
56
+ + `ses ${layerStatus(browser.sandbox?.ses)}`);
49
57
 
50
58
  const shutdown = () => {
51
59
  server.stop(true);
@@ -67,10 +75,16 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
67
75
  * not wait for CanvasKit and fonts to load. Site isolation is by origin
68
76
  * because no Public Suffix List is bundled to compute sites.
69
77
  *
70
- * @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number }} options
78
+ * With `waitForRenderer: false` it returns before the first renderer is up
79
+ * (`ready` settles then): navigations fetch their documents meanwhile, and
80
+ * anything that needs a renderer waits for it.
81
+ *
82
+ * @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
83
+ * waitForRenderer?: boolean }} options
71
84
  */
72
85
  export async function createBrowser({
73
86
  allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
87
+ waitForRenderer = true,
74
88
  } = {}) {
75
89
  // --mobile fixes the user agent for page requests and navigator.userAgent.
76
90
  network.userAgent = mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT;
@@ -83,6 +97,8 @@ export async function createBrowser({
83
97
  let currentKey = null;
84
98
  let spare = null;
85
99
  let closed = false;
100
+ // Settles when the first renderer shows about:blank (assigned below).
101
+ let rendererReady = null;
86
102
  // Renderer-independent state replayed into every new renderer.
87
103
  const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
88
104
  let userAgent = null;
@@ -216,7 +232,21 @@ export async function createBrowser({
216
232
  return current;
217
233
  }
218
234
 
219
- const call = (method, ...params) => current.call(method, ...params);
235
+ const call = async (method, ...params) => {
236
+ await rendererReady;
237
+ return current.call(method, ...params);
238
+ };
239
+
240
+ /**
241
+ * Applies viewport or user agent state the renderer is told about. Before
242
+ * the first renderer is up there is nothing to wait for: it starts with the
243
+ * state (replayState), and the latest state is applied again once it is.
244
+ */
245
+ function applyState(method, args) {
246
+ if (current) return current.call(method, ...args());
247
+ rendererReady.then(() => current?.call(method, ...args())).catch(() => {});
248
+ return undefined;
249
+ }
220
250
 
221
251
  /**
222
252
  * Fetches a top-level document, then commits it in the renderer for its
@@ -266,6 +296,7 @@ export async function createBrowser({
266
296
  const fetched = performance.now();
267
297
  writeLog("navigation", `${url}: ${document.error ?? `HTTP ${document.status ?? 200}`}`
268
298
  + `${document.url === url ? "" : ` -> ${document.url}`} in ${Math.round(fetched - started)}ms`);
299
+ if (!invoke) await rendererReady;
269
300
  const host = invoke ? current : await rendererFor(document.url);
270
301
  const generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
271
302
  committed = { host, generation };
@@ -275,9 +306,14 @@ export async function createBrowser({
275
306
 
276
307
  // Screenshots and scrolling go to the compositor thread, so they are
277
308
  // answered even while the renderer's main thread is busy.
278
- const composite = (method, ...params) => current.composite(method, ...params);
309
+ const composite = async (method, ...params) => {
310
+ await rendererReady;
311
+ return current.composite(method, ...params);
312
+ };
279
313
 
280
314
  async function rememberScroll() {
315
+ // Before the first renderer is up nothing has been shown or scrolled.
316
+ if (!current) return;
281
317
  const offset = await composite("scrollOffset");
282
318
  Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
283
319
  }
@@ -336,14 +372,14 @@ export async function createBrowser({
336
372
  goForward: () => traverseHistory(historyIndex + 1),
337
373
  resize(width, height, deviceScaleFactor = 1) {
338
374
  Object.assign(viewport, { width, height, deviceScaleFactor });
339
- return call("resize", ...resizeArguments());
375
+ return applyState("resize", resizeArguments);
340
376
  },
341
377
  setUserAgent(value) {
342
378
  // With --mobile the phone user agent stays (casty, for one, overrides it with a desktop one).
343
379
  if (mobile) return undefined;
344
380
  userAgent = typeof value === "string" && value ? value : null;
345
381
  network.userAgent = userAgent;
346
- return userAgent ? call("setUserAgent", userAgent) : undefined;
382
+ return userAgent ? applyState("setUserAgent", () => [userAgent]) : undefined;
347
383
  },
348
384
  screenshot: (options = {}) => composite("screenshot", {
349
385
  format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
@@ -401,12 +437,21 @@ export async function createBrowser({
401
437
  },
402
438
  };
403
439
 
404
- await rendererFor("about:blank");
405
- await current.call("loadDocument", { url: "about:blank" });
440
+ // The spare starts with the first renderer rather than after it, ready
441
+ // for the first navigation to an origin.
442
+ rendererReady = (async () => {
443
+ const first = rendererFor("about:blank");
444
+ prepareSpare();
445
+ await first;
446
+ await current.call("loadDocument", { url: "about:blank" });
447
+ })();
448
+ if (waitForRenderer) await rendererReady;
449
+ else rendererReady.catch(() => {}); // the caller awaits `ready`
406
450
 
407
451
  return {
408
452
  context,
409
453
  network,
454
+ ready: rendererReady,
410
455
  get sandbox() {
411
456
  return current?.sandbox ?? null;
412
457
  },
@@ -39,10 +39,11 @@ export function layoutText(tree, options = {}) {
39
39
  boxes: [],
40
40
  lineHeight: options.lineHeight == null ? null : positiveNumber(options.lineHeight, 31),
41
41
  fallbackLineHeight: 31,
42
- measure: cachedMeasure(measureText),
43
- metrics: cachedMetrics(options.fontMetrics),
44
- maxContent: new WeakMap(),
45
- minContent: new WeakMap(),
42
+ measure: options.measureCache ?? cachedMeasure(measureText),
43
+ metrics: options.metricsCache ?? cachedMetrics(options.fontMetrics),
44
+ maxContent: options.maxContentCache ?? new WeakMap(),
45
+ minContent: options.minContentCache ?? new WeakMap(),
46
+ blockResults: options.blockResultCache ?? null,
46
47
  // Paint layers: the root layer holds in-flow content; positioned boxes add
47
48
  // more. A layer with an integer z-index is a stacking context; `parent` is
48
49
  // the stacking context a layer is painted in (CSS 2 Appendix E).
@@ -50,14 +51,20 @@ export function layoutText(tree, options = {}) {
50
51
  stackingContext: ROOT_LAYER,
51
52
  layerCount: 1,
52
53
  initialContainingBlock: null,
54
+ // Static positions recorded for out-of-flow boxes, in order (see
55
+ // cacheBlockResult), and those boxes by node for replays.
56
+ staticPositions: [],
57
+ outOfFlowBoxes: new Map(),
53
58
  };
54
59
 
55
60
  containingBlocks = [];
61
+ outOfFlowBoxes = ctx.outOfFlowBoxes;
56
62
  let root;
57
63
  try {
58
64
  root = buildBox(tree.root, inheritedFlags(tree.root, { preserve: false, wrap: true }));
59
65
  } finally {
60
66
  containingBlocks = null;
67
+ outOfFlowBoxes = null;
61
68
  }
62
69
  const containingWidth = options.measureText || viewportWidth
63
70
  ? Math.max(1, (viewportWidth || columns * columnWidth) - x * 2)
@@ -91,12 +98,43 @@ export function layoutText(tree, options = {}) {
91
98
  });
92
99
  }
93
100
 
101
+ /**
102
+ * Long-lived layout entry point. Expensive shaping measurements are valid as
103
+ * long as their immutable computed-style identity is valid, so retain them
104
+ * across incremental updates instead of starting cold on every reflow.
105
+ * Formatting-context result caches are added to this same owner.
106
+ */
107
+ export class LayoutEngine {
108
+ #measure;
109
+ #metrics;
110
+ #maxContent = new WeakMap();
111
+ #minContent = new WeakMap();
112
+ #blockResults = new WeakMap();
113
+
114
+ constructor({ measureText = (text) => Bun.stringWidth(text) * 13, fontMetrics = null } = {}) {
115
+ this.#measure = cachedMeasure(measureText);
116
+ this.#metrics = cachedMetrics(fontMetrics);
117
+ }
118
+
119
+ layout(tree, options = {}) {
120
+ return layoutText(tree, {
121
+ ...options,
122
+ measureCache: this.#measure,
123
+ metricsCache: this.#metrics,
124
+ maxContentCache: this.#maxContent,
125
+ minContentCache: this.#minContent,
126
+ blockResultCache: this.#blockResults,
127
+ });
128
+ }
129
+ }
130
+
94
131
  // ---------------------------------------------------------------------------
95
132
  // Box tree construction
96
133
 
97
134
  // Absolutely positioned boxes register with their containing block while the
98
135
  // box tree is built. Building is synchronous and scoped to layoutText.
99
136
  let containingBlocks = null;
137
+ let outOfFlowBoxes = null;
100
138
 
101
139
  // Text nodes carry their parent's style, so only element boxes can be positioned.
102
140
  const UNPOSITIONABLE = new Set(["root", "text", "line-break"]);
@@ -115,6 +153,7 @@ function outOfFlowBox(node, flags) {
115
153
  const box = buildBox(node, inheritedFlags(node, flags), true);
116
154
  box.outOfFlow = true;
117
155
  holder.absolutes.push(box);
156
+ outOfFlowBoxes?.set(node, box);
118
157
  return box;
119
158
  }
120
159
 
@@ -278,9 +317,13 @@ function hasInlineContent(items) {
278
317
  * then applies relative offsets and translations to everything it produced.
279
318
  */
280
319
  function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
320
+ const cached = cachedBlockResult(ctx, box, width, y, x, absorbTop, forcedHeight);
321
+ if (cached) return cached;
281
322
  const style = box.style;
282
323
  const boxStart = ctx.boxes.length;
283
324
  const fragmentStart = ctx.fragments.length;
325
+ const layerStart = ctx.layerCount;
326
+ const staticStart = ctx.staticPositions.length;
284
327
  const previousLayer = ctx.layer;
285
328
  const previousContext = ctx.stackingContext;
286
329
  // Anonymous boxes reuse their container's node but are never positioned themselves.
@@ -314,9 +357,122 @@ function layoutBlock(ctx, box, x, width, y, absorbTop = false, forcedHeight = nu
314
357
  if (dx || dy) shiftOutput(ctx, boxStart, fragmentStart, dx, dy);
315
358
  ctx.layer = previousLayer;
316
359
  ctx.stackingContext = previousContext;
360
+ cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, {
361
+ boxStart, fragmentStart, layerStart, staticStart, layer: previousLayer, context: previousContext,
362
+ });
317
363
  return result;
318
364
  }
319
365
 
366
+ function blockCacheKey(width, absorbTop, forcedHeight) {
367
+ return `${width}\n${absorbTop ? 1 : 0}\n${forcedHeight ?? "auto"}`;
368
+ }
369
+
370
+ /** Where an out-of-flow box would have been in flow; recorded so that a replayed block sets it again. */
371
+ function setStaticPosition(ctx, box, position) {
372
+ box.staticPosition = position;
373
+ ctx.staticPositions.push({ node: box.node, position });
374
+ }
375
+
376
+ /** Replays a retained formatting-context result at its new flow position. */
377
+ function cachedBlockResult(ctx, box, width, y, x, absorbTop, forcedHeight) {
378
+ if (!ctx.blockResults || box.anonymous) return null;
379
+ const cached = ctx.blockResults.get(box.node)?.get(blockCacheKey(width, absorbTop, forcedHeight));
380
+ if (!cached) return null;
381
+ const dx = x - cached.x;
382
+ const dy = y - cached.y;
383
+ // The block's own paint layers are made again, in their order, after the
384
+ // layers laid out before it; -1 is the layer and stacking context it is in.
385
+ const layers = [];
386
+ for (const layer of cached.layers) {
387
+ layers.push(Object.freeze({
388
+ z: layer.z,
389
+ order: ctx.layerCount++,
390
+ context: layer.context,
391
+ parent: layer.parent === -1 ? ctx.stackingContext : layers[layer.parent],
392
+ }));
393
+ }
394
+ const layerAt = (index) => index === -1 ? ctx.layer : layers[index];
395
+ // Retained output is frozen: where it neither moved nor changed layer, the
396
+ // same objects are output again.
397
+ const moved = dx !== 0 || dy !== 0;
398
+ cached.boxes.forEach((record, index) => {
399
+ const layer = layerAt(cached.boxLayers[index]);
400
+ ctx.boxes.push(!moved && record.layer === layer ? record : { ...record, x: record.x + dx, y: record.y + dy, layer });
401
+ });
402
+ cached.fragments.forEach((fragment, index) => {
403
+ const layer = layerAt(cached.fragmentLayers[index]);
404
+ ctx.fragments.push(!moved && fragment.layer === layer ? fragment : { ...shiftedFragment(fragment, dx, dy), layer });
405
+ });
406
+ // Out-of-flow boxes whose containing block is outside this one still need their static positions.
407
+ for (const { node, x: staticX, y: staticY } of cached.staticPositions) {
408
+ const outOfFlow = ctx.outOfFlowBoxes.get(node);
409
+ const position = { x: staticX + dx, y: staticY + dy };
410
+ if (outOfFlow) outOfFlow.staticPosition = position;
411
+ ctx.staticPositions.push({ node, position });
412
+ }
413
+ return {
414
+ height: cached.result.height,
415
+ baseline: cached.result.baseline == null ? null : cached.result.baseline + dy,
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Retains a block's output for replay (cachedBlockResult). Positioned boxes
421
+ * inside it make paint layers of their own; those are kept by creation
422
+ * order relative to the layer and stacking context the block is in, and the
423
+ * static positions it recorded go along. Output in a layer made before the
424
+ * block (which should not occur) is not retained.
425
+ */
426
+ function cacheBlockResult(ctx, box, width, x, y, absorbTop, forcedHeight, result, start) {
427
+ if (!ctx.blockResults || box.anonymous) return;
428
+ const boxes = ctx.boxes.slice(start.boxStart);
429
+ const fragments = ctx.fragments.slice(start.fragmentStart);
430
+ const local = new Set();
431
+ const collect = (layer) => {
432
+ if (layer === start.layer || layer === start.context || local.has(layer)) return true;
433
+ if (!layer || layer.order < start.layerStart) return false;
434
+ local.add(layer);
435
+ return collect(layer.parent);
436
+ };
437
+ if (!boxes.every((record) => collect(record.layer)) || !fragments.every((fragment) => collect(fragment.layer))) return;
438
+ const ordered = [...local].sort((left, right) => left.order - right.order);
439
+ const indices = new Map(ordered.map((layer, index) => [layer, index]));
440
+ const layers = [];
441
+ for (const layer of ordered) {
442
+ const parent = indices.get(layer.parent) ?? (layer.parent === start.context ? -1 : null);
443
+ if (parent === null) return;
444
+ layers.push({ z: layer.z, context: layer.context, parent });
445
+ }
446
+ const indexOf = (layer) => layer === start.layer ? -1 : indices.get(layer) ?? null;
447
+ const boxLayers = boxes.map((record) => indexOf(record.layer));
448
+ const fragmentLayers = fragments.map((fragment) => indexOf(fragment.layer));
449
+ if (boxLayers.includes(null) || fragmentLayers.includes(null)) return;
450
+ let entries = ctx.blockResults.get(box.node);
451
+ if (!entries) ctx.blockResults.set(box.node, entries = new Map());
452
+ // A node normally sees one containing width. Bound unusual cases such as a
453
+ // reused table cell so the retained cache cannot grow without limit.
454
+ if (!entries.has(blockCacheKey(width, absorbTop, forcedHeight)) && entries.size >= 4) {
455
+ entries.delete(entries.keys().next().value);
456
+ }
457
+ entries.set(blockCacheKey(width, absorbTop, forcedHeight), {
458
+ x,
459
+ y,
460
+ result: { ...result },
461
+ layers,
462
+ boxes: boxes.map(retained),
463
+ boxLayers,
464
+ fragments: fragments.map(retained),
465
+ fragmentLayers,
466
+ staticPositions: ctx.staticPositions.slice(start.staticStart)
467
+ .map(({ node, position }) => ({ node, x: position.x, y: position.y })),
468
+ });
469
+ }
470
+
471
+ /** An output record as retained: frozen (records replayed from a retained block already are). */
472
+ function retained(record) {
473
+ return Object.isFrozen(record) ? record : Object.freeze({ ...record });
474
+ }
475
+
320
476
  /** CSS 2 §10.3.7/§10.6.4 absolute placement against the padding box. */
321
477
  function layoutAbsolutes(ctx, box, record) {
322
478
  const border = borderWidths(box.style);
@@ -387,10 +543,11 @@ function justifyColumn(ctx, items, justify = "normal", free) {
387
543
  });
388
544
  }
389
545
 
546
+ /** Moves output already produced; records are replaced, as retained ones are frozen and shared. */
390
547
  function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
391
548
  for (let index = boxStart; index < ctx.boxes.length; index++) {
392
- ctx.boxes[index].x += dx;
393
- ctx.boxes[index].y += dy;
549
+ const record = ctx.boxes[index];
550
+ ctx.boxes[index] = { ...record, x: record.x + dx, y: record.y + dy };
394
551
  }
395
552
  for (let index = fragmentStart; index < ctx.fragments.length; index++) {
396
553
  ctx.fragments[index] = shiftedFragment(ctx.fragments[index], dx, dy);
@@ -398,6 +555,7 @@ function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
398
555
  }
399
556
 
400
557
  function shiftedFragment(fragment, dx, dy) {
558
+ if (dx === 0 && dy === 0) return fragment;
401
559
  return {
402
560
  ...fragment,
403
561
  x: fragment.x + dx,
@@ -500,7 +658,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
500
658
  let firstInFlow = true;
501
659
  box.children.forEach((child) => {
502
660
  if (child.kind === "placeholder") {
503
- child.box.staticPosition = { x: contentX, y: cursor + strutValue(pending ?? ZERO_STRUT) };
661
+ setStaticPosition(ctx, child.box, { x: contentX, y: cursor + strutValue(pending ?? ZERO_STRUT) });
504
662
  return;
505
663
  }
506
664
  const first = firstInFlow;
@@ -902,7 +1060,10 @@ function alignGridContent(ctx, grid, alignment = "normal", free) {
902
1060
  for (const placement of grid.placements) {
903
1061
  const dy = offset(placement.row);
904
1062
  if (!dy) continue;
905
- for (let index = placement.boxStart; index < placement.boxEnd; index++) ctx.boxes[index].y += dy;
1063
+ for (let index = placement.boxStart; index < placement.boxEnd; index++) {
1064
+ const record = ctx.boxes[index];
1065
+ ctx.boxes[index] = { ...record, y: record.y + dy };
1066
+ }
906
1067
  for (let index = placement.fragmentStart; index < placement.fragmentEnd; index++) {
907
1068
  ctx.fragments[index] = shiftedFragment(ctx.fragments[index], 0, dy);
908
1069
  }
@@ -1532,7 +1693,8 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, perce
1532
1693
  }
1533
1694
 
1534
1695
  function maxContentWidth(ctx, box) {
1535
- const cached = ctx.maxContent.get(box);
1696
+ const cacheKey = box.node ?? box;
1697
+ const cached = ctx.maxContent.get(cacheKey);
1536
1698
  if (cached != null) return cached;
1537
1699
  const style = box.style;
1538
1700
  const padding = style?.padding ?? ZERO_BOX;
@@ -1568,12 +1730,13 @@ function maxContentWidth(ctx, box) {
1568
1730
  }
1569
1731
  const maxWidth = numberOrNull(style?.maxWidth);
1570
1732
  if (maxWidth != null) width = Math.min(width, style.boxSizing === "border-box" ? maxWidth : maxWidth + extra);
1571
- ctx.maxContent.set(box, width);
1733
+ ctx.maxContent.set(cacheKey, width);
1572
1734
  return width;
1573
1735
  }
1574
1736
 
1575
1737
  function minContentWidth(ctx, box) {
1576
- const cached = ctx.minContent.get(box);
1738
+ const cacheKey = box.node ?? box;
1739
+ const cached = ctx.minContent.get(cacheKey);
1577
1740
  if (cached != null) return cached;
1578
1741
  const style = box.style;
1579
1742
  const padding = style?.padding ?? ZERO_BOX;
@@ -1600,7 +1763,7 @@ function minContentWidth(ctx, box) {
1600
1763
  ? inlineMinContentWidth(ctx, child)
1601
1764
  : outer(child))) + extra;
1602
1765
  }
1603
- ctx.minContent.set(box, width);
1766
+ ctx.minContent.set(cacheKey, width);
1604
1767
  return width;
1605
1768
  }
1606
1769
 
@@ -2220,11 +2383,12 @@ function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline
2220
2383
  const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
2221
2384
 
2222
2385
  function cachedMeasure(measureText) {
2223
- const byStyle = new Map();
2386
+ const byStyle = new WeakMap();
2387
+ const defaultStyle = new Map();
2224
2388
  return (text, style) => {
2225
2389
  if (!text) return 0;
2226
- let cache = byStyle.get(style ?? null);
2227
- if (!cache) byStyle.set(style ?? null, cache = new Map());
2390
+ let cache = style && typeof style === "object" ? byStyle.get(style) : defaultStyle;
2391
+ if (!cache) byStyle.set(style, cache = new Map());
2228
2392
  let width = cache.get(text);
2229
2393
  if (width === undefined) cache.set(text, width = measureText(text, style ?? null));
2230
2394
  return width;