react-x11 2.5.0 → 2.6.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.
@@ -13,9 +13,24 @@ import { cssColorStraight } from 'ntk';
13
13
 
14
14
  const BLACK = [0, 0, 0, 1];
15
15
 
16
+ // A colour string is parsed once: a frame over a large tree sets the same
17
+ // few fills thousands of times, and the parse — a regex and four numbers —
18
+ // cost a third of `_applyFill` (measured on the presenter bench's `tiny`
19
+ // cell, 5,000 fills of two colours: 100ms of a 200ms frame). Bounded, and
20
+ // dropped whole rather than evicted, since a palette is a few dozen strings.
21
+ const parsedColors = new Map();
22
+ const PARSED_COLORS_MAX = 256;
23
+
16
24
  function parseColor(value) {
17
25
  if (value == null) return BLACK;
18
- return cssColorStraight(String(value)) ?? BLACK;
26
+ const key = typeof value === 'string' ? value : String(value);
27
+ let parsed = parsedColors.get(key);
28
+ if (parsed === undefined) {
29
+ parsed = cssColorStraight(key) ?? BLACK;
30
+ if (parsedColors.size >= PARSED_COLORS_MAX) parsedColors.clear();
31
+ parsedColors.set(key, parsed);
32
+ }
33
+ return parsed;
19
34
  }
20
35
 
21
36
  class LinearGradient {
@@ -77,6 +92,61 @@ const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
77
92
  const PICT_OP = Object.freeze({ Src: 1, Over: 3 });
78
93
  const RENDER = Object.freeze({ PictOp: PICT_OP });
79
94
 
95
+ /**
96
+ * The path, recorded alongside the native one, so `stroke` can re-issue it
97
+ * in pieces — `CGContextStrokePath` is QUADRATIC in the number of subpaths
98
+ * in the path it is given (issue #456). Measured here, 13-vertex closed
99
+ * rings scattered over a 1024x1024 surface at a 2px line, one stroke call:
100
+ *
101
+ * 500 rings 20ms | 1000 rings 59ms | 2000 rings 208ms | 4000 rings 986ms
102
+ *
103
+ * Splitting the same geometry into strokes of a few hundred subpaths is
104
+ * linear in it: 14ms, 29ms, 56ms, 113ms. The driver is the subpath count,
105
+ * not the vertex count — one 26,000-vertex subpath strokes in 4ms where
106
+ * two thousand 13-vertex ones take 208. The full table, the two shapes
107
+ * left whole and why, are in docs/macos.md
108
+ * §"Stroking a path with many subpaths".
109
+ *
110
+ * Note the X11 context wants the opposite — there a stroke is an a8
111
+ * coverage mask over the path's bounding box uploaded with one PutImage,
112
+ * so a bigger path is fewer uploads over the same pixels. That is why this
113
+ * lives in the backend: a caller that batches for one backend pessimizes
114
+ * the other, and it cannot know which it is drawing on.
115
+ *
116
+ * The commands are a flat number array — `[op, ...args, op, ...args]` —
117
+ * reused across paths, so a path build is three pushes into a packed
118
+ * double array per point next to the napi call it already makes.
119
+ */
120
+ const P_MOVE = 0;
121
+ const P_LINE = 1;
122
+ const P_CURVE = 2;
123
+ const P_QUAD = 3;
124
+ const P_CLOSE = 4;
125
+ const P_RECT = 5;
126
+ const P_ROUND = 6;
127
+ const P_ARC = 7;
128
+ const P_ELLIPSE = 8;
129
+ /** how many numbers each op carries, and what it costs a chunk's budget */
130
+ const P_ARGS = [2, 2, 6, 4, 0, 4, 8, 6, 4];
131
+ const P_POINTS = [1, 1, 3, 2, 0, 4, 8, 8, 4];
132
+
133
+ /**
134
+ * A chunk closes at the first subpath boundary past either budget. Swept
135
+ * over the shapes above: 512 points is within 5% of the best chunk for
136
+ * every one of them, and the subpath cap catches the degenerate shape the
137
+ * point budget misses — thousands of 3- and 4-point subpaths, where 512
138
+ * points is already 128 strokes' worth of setup.
139
+ */
140
+ const STROKE_CHUNK_POINTS = 512;
141
+ const STROKE_CHUNK_SUBPATHS = 128;
142
+
143
+ /**
144
+ * The off switch, for a process that cannot reach the context — the same
145
+ * line `ctx.strokeChunking = false` draws, and what a bench comparing the
146
+ * two sets. Read once.
147
+ */
148
+ const NO_STROKE_CHUNKING = process.env.REACT_X11_NO_STROKE_CHUNKING === '1';
149
+
80
150
  /**
81
151
  * A solid ink for `drawGlyphs` — ntk's `createSolidPicture` answers an
82
152
  * XRender picture; here it is the colour itself. Premultiplied 0..1 in, as
@@ -121,6 +191,11 @@ export class CocoaContext2D {
121
191
  ctm: [1, 0, 0, 1, 0, 0],
122
192
  };
123
193
  this._onDirty = null;
194
+ // the recorded path, and whether the native one still matches it (a
195
+ // chunked stroke leaves only its last chunk behind)
196
+ this._cmds = [];
197
+ this._pathStale = false;
198
+ this._strokeChunking = !NO_STROKE_CHUNKING;
124
199
  }
125
200
 
126
201
  _s() {
@@ -137,6 +212,10 @@ export class CocoaContext2D {
137
212
  n.ctxSetGlobalAlpha(surface, st.globalAlpha);
138
213
  n.ctxSetLineDash(surface, st.dash, st.dashOffset);
139
214
  this._stack.length = 0;
215
+ // the path went with the surface it was built on; nothing may
216
+ // replay it onto the new one
217
+ this._cmds.length = 0;
218
+ this._pathStale = false;
140
219
  }
141
220
  return surface;
142
221
  }
@@ -203,6 +282,26 @@ export class CocoaContext2D {
203
282
  }
204
283
  }
205
284
 
285
+ /**
286
+ * Whether a stroke of a path with many subpaths may go out as several
287
+ * `CGContextStrokePath` calls — on by default, because the alternative
288
+ * is quadratic (see P_MOVE above) and every path small enough for the
289
+ * difference to be invisible is below the threshold anyway.
290
+ *
291
+ * Set it false for a path whose subpaths OVERLAP and whose seams have to
292
+ * composite exactly: chunked, a pixel the strokes of two subpaths each
293
+ * half cover is inked twice at half coverage rather than once at full,
294
+ * and reads a little lighter. `REACT_X11_NO_STROKE_CHUNKING=1` is the
295
+ * same switch for a whole process.
296
+ */
297
+ get strokeChunking() {
298
+ return this._strokeChunking;
299
+ }
300
+
301
+ set strokeChunking(value) {
302
+ this._strokeChunking = !!value;
303
+ }
304
+
206
305
  get font() {
207
306
  return this._state.font;
208
307
  }
@@ -374,20 +473,96 @@ export class CocoaContext2D {
374
473
 
375
474
  // --- paths ---------------------------------------------------------------
376
475
 
476
+ /**
477
+ * The surface to build or paint the current path on, with the native
478
+ * path restored first if a chunked stroke consumed it. Lazy on purpose:
479
+ * a caller that strokes and then starts a new path — which is every
480
+ * caller in a paint loop — never pays for the rebuild.
481
+ */
482
+ _path() {
483
+ const surface = this._s();
484
+ if (this._pathStale) {
485
+ this._pathStale = false;
486
+ this._native.ctxBeginPath(surface);
487
+ this._emit(surface, 0, this._cmds.length);
488
+ }
489
+ return surface;
490
+ }
491
+
492
+ /** replay recorded commands `[from, to)` into the native path */
493
+ _emit(surface, from, to) {
494
+ const c = this._cmds;
495
+ const n = this._native;
496
+ for (let i = from; i < to;) {
497
+ const op = c[i];
498
+ const a = i + 1;
499
+ if (op === P_MOVE) n.ctxMoveTo(surface, c[a], c[a + 1]);
500
+ else if (op === P_LINE) n.ctxLineTo(surface, c[a], c[a + 1]);
501
+ else if (op === P_CURVE)
502
+ n.ctxCurveTo(
503
+ surface,
504
+ c[a],
505
+ c[a + 1],
506
+ c[a + 2],
507
+ c[a + 3],
508
+ c[a + 4],
509
+ c[a + 5],
510
+ );
511
+ else if (op === P_QUAD)
512
+ n.ctxQuadTo(surface, c[a], c[a + 1], c[a + 2], c[a + 3]);
513
+ else if (op === P_CLOSE) n.ctxClosePath(surface);
514
+ else if (op === P_RECT)
515
+ n.ctxRect(surface, c[a], c[a + 1], c[a + 2], c[a + 3]);
516
+ else if (op === P_ROUND)
517
+ n.ctxRoundRect(
518
+ surface,
519
+ c[a],
520
+ c[a + 1],
521
+ c[a + 2],
522
+ c[a + 3],
523
+ c[a + 4],
524
+ c[a + 5],
525
+ c[a + 6],
526
+ c[a + 7],
527
+ );
528
+ else if (op === P_ARC)
529
+ n.ctxArc(
530
+ surface,
531
+ c[a],
532
+ c[a + 1],
533
+ c[a + 2],
534
+ c[a + 3],
535
+ c[a + 4],
536
+ !!c[a + 5],
537
+ );
538
+ else if (op === P_ELLIPSE)
539
+ n.ctxEllipse(surface, c[a], c[a + 1], c[a + 2], c[a + 3]);
540
+ i += 1 + P_ARGS[op];
541
+ }
542
+ }
543
+
377
544
  beginPath() {
545
+ this._cmds.length = 0;
546
+ this._pathStale = false;
378
547
  this._native.ctxBeginPath(this._s());
379
548
  }
380
549
 
381
550
  moveTo(x, y) {
382
- this._native.ctxMoveTo(this._s(), x, y);
551
+ const surface = this._path();
552
+ this._cmds.push(P_MOVE, x, y);
553
+ this._native.ctxMoveTo(surface, x, y);
383
554
  }
384
555
 
385
556
  lineTo(x, y) {
386
- this._native.ctxLineTo(this._s(), x, y);
557
+ const surface = this._path();
558
+ this._cmds.push(P_LINE, x, y);
559
+ this._native.ctxLineTo(surface, x, y);
387
560
  }
388
561
 
389
562
  rect(x, y, w, h) {
390
- this._native.ctxRect(this._s(), x, y, w, h);
563
+ const surface = this._path();
564
+ this._cmds.push(P_RECT, x, y, w, h);
565
+ this._native.ctxRect(surface, x, y, w, h);
391
566
  }
392
567
 
393
568
  roundRect(x, y, w, h, radii) {
@@ -398,37 +573,45 @@ export class CocoaContext2D {
398
573
  else if (r.length === 3) r = [r[0], r[1], r[2], r[1]];
399
574
  const cap = Math.min(Math.abs(w) / 2, Math.abs(h) / 2);
400
575
  const clamp = (v) => Math.max(0, Math.min(Number(v) || 0, cap));
401
- this._native.ctxRoundRect(
402
- this._s(),
403
- x,
404
- y,
405
- w,
406
- h,
576
+ const surface = this._path();
577
+ const [r0, r1, r2, r3] = [
407
578
  clamp(r[0]),
408
579
  clamp(r[1]),
409
580
  clamp(r[2]),
410
581
  clamp(r[3]),
411
- );
582
+ ];
583
+ this._cmds.push(P_ROUND, x, y, w, h, r0, r1, r2, r3);
584
+ this._native.ctxRoundRect(surface, x, y, w, h, r0, r1, r2, r3);
412
585
  }
413
586
 
414
587
  arc(x, y, radius, start, end, anticlockwise = false) {
415
- this._native.ctxArc(this._s(), x, y, radius, start, end, anticlockwise);
588
+ const surface = this._path();
589
+ this._cmds.push(P_ARC, x, y, radius, start, end, anticlockwise ? 1 : 0);
590
+ this._native.ctxArc(surface, x, y, radius, start, end, anticlockwise);
416
591
  }
417
592
 
418
593
  ellipse(x, y, rx, ry) {
419
- this._native.ctxEllipse(this._s(), x, y, rx, ry);
594
+ const surface = this._path();
595
+ this._cmds.push(P_ELLIPSE, x, y, rx, ry);
596
+ this._native.ctxEllipse(surface, x, y, rx, ry);
420
597
  }
421
598
 
422
599
  bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
423
- this._native.ctxCurveTo(this._s(), c1x, c1y, c2x, c2y, x, y);
600
+ const surface = this._path();
601
+ this._cmds.push(P_CURVE, c1x, c1y, c2x, c2y, x, y);
602
+ this._native.ctxCurveTo(surface, c1x, c1y, c2x, c2y, x, y);
424
603
  }
425
604
 
426
605
  quadraticCurveTo(cx, cy, x, y) {
427
- this._native.ctxQuadTo(this._s(), cx, cy, x, y);
606
+ const surface = this._path();
607
+ this._cmds.push(P_QUAD, cx, cy, x, y);
608
+ this._native.ctxQuadTo(surface, cx, cy, x, y);
428
609
  }
429
610
 
430
611
  closePath() {
431
- this._native.ctxClosePath(this._s());
612
+ const surface = this._path();
613
+ this._cmds.push(P_CLOSE);
614
+ this._native.ctxClosePath(surface);
432
615
  }
433
616
 
434
617
  // --- painting ------------------------------------------------------------
@@ -462,24 +645,95 @@ export class CocoaContext2D {
462
645
  _replayPath(path) {
463
646
  const cmds = path?._cmds;
464
647
  if (!Array.isArray(cmds)) return false;
465
- const n = this._native;
466
- const s = this._s();
467
- n.ctxBeginPath(s);
648
+ this.beginPath();
468
649
  for (const c of cmds) {
469
- if (c.type === 'M') n.ctxMoveTo(s, c.x, c.y);
470
- else if (c.type === 'L') n.ctxLineTo(s, c.x, c.y);
650
+ if (c.type === 'M') this.moveTo(c.x, c.y);
651
+ else if (c.type === 'L') this.lineTo(c.x, c.y);
471
652
  else if (c.type === 'C')
472
- n.ctxCurveTo(s, c.x1, c.y1, c.x2, c.y2, c.x, c.y);
473
- else if (c.type === 'Q') n.ctxQuadTo(s, c.x1, c.y1, c.x, c.y);
474
- else if (c.type === 'Z') n.ctxClosePath(s);
653
+ this.bezierCurveTo(c.x1, c.y1, c.x2, c.y2, c.x, c.y);
654
+ else if (c.type === 'Q') this.quadraticCurveTo(c.x1, c.y1, c.x, c.y);
655
+ else if (c.type === 'Z') this.closePath();
475
656
  }
476
657
  return true;
477
658
  }
478
659
 
660
+ /**
661
+ * Whether a stroke of the current path may be split into several
662
+ * `CGContextStrokePath` calls. Two things say no:
663
+ *
664
+ * - A hairline. At or below a device-space width of 1 CoreGraphics
665
+ * strokes through a path that is already linear in the subpath count
666
+ * — 2,000 rings cost 7ms whole — and splitting it is a 2x LOSS, the
667
+ * per-call setup with nothing to win back.
668
+ * - Anything that composites. Each chunk paints separately, so where two
669
+ * subpaths' strokes overlap, a translucent ink, a globalAlpha or a
670
+ * shadow blends twice and reads darker where one call blends the union
671
+ * once. An opaque ink is exact everywhere the coverage is full, which
672
+ * is what the geometry that gets big looks like; what is left is the
673
+ * antialiased fringe at those same overlaps, half-covered twice
674
+ * instead of covered once, which reads a little lighter — the one
675
+ * difference `strokeChunking` exists to turn off.
676
+ */
677
+ _chunkableStroke() {
678
+ if (!this._strokeChunking) return false;
679
+ const st = this._state;
680
+ const [a, b, c, d] = st.ctm;
681
+ const scale = Math.sqrt(Math.abs(a * d - b * c));
682
+ if (!(st.lineWidth * scale > 1)) return false;
683
+ if (st.globalAlpha < 1) return false;
684
+ if (parseColor(st.strokeStyle)[3] < 1) return false;
685
+ if (st.shadowBlur > 0 && parseColor(st.shadowColor)[3] > 0) return false;
686
+ return true;
687
+ }
688
+
689
+ /**
690
+ * Stroke the recorded path as a series of chunks, cut at subpath
691
+ * boundaries so every chunk carries the `moveTo` its segments start
692
+ * from. Answers false when there was nothing to split, leaving the
693
+ * native path untouched for the caller's single stroke.
694
+ */
695
+ _strokeChunks(surface) {
696
+ if (!this._chunkableStroke()) return false;
697
+ const cmds = this._cmds;
698
+ const n = this._native;
699
+ let start = 0;
700
+ let points = 0;
701
+ let subpaths = 0;
702
+ let split = false;
703
+ for (let i = 0; i < cmds.length;) {
704
+ const op = cmds[i];
705
+ if (
706
+ op === P_MOVE &&
707
+ i > start &&
708
+ (points >= STROKE_CHUNK_POINTS || subpaths >= STROKE_CHUNK_SUBPATHS)
709
+ ) {
710
+ n.ctxBeginPath(surface);
711
+ this._emit(surface, start, i);
712
+ n.ctxStroke(surface);
713
+ split = true;
714
+ start = i;
715
+ points = 0;
716
+ subpaths = 0;
717
+ }
718
+ if (op === P_MOVE) subpaths++;
719
+ points += P_POINTS[op];
720
+ i += 1 + P_ARGS[op];
721
+ }
722
+ if (!split) return false;
723
+ n.ctxBeginPath(surface);
724
+ this._emit(surface, start, cmds.length);
725
+ n.ctxStroke(surface);
726
+ // the native path is the last chunk now; _path() puts the whole one
727
+ // back if anything asks for it
728
+ this._pathStale = true;
729
+ return true;
730
+ }
731
+
479
732
  fill(pathOrRule, maybeRule) {
480
733
  const hasPath = pathOrRule != null && typeof pathOrRule === 'object';
481
734
  const rule = hasPath ? maybeRule : pathOrRule;
482
735
  if (hasPath && !this._replayPath(pathOrRule)) return;
736
+ this._path();
483
737
  const style = this._state.fillStyle;
484
738
  if (style instanceof LinearGradient) {
485
739
  const { coords, flat } = style._normalized();
@@ -503,7 +757,14 @@ export class CocoaContext2D {
503
757
  return;
504
758
  }
505
759
  this._applyStroke();
506
- this._native.ctxStroke(this._s());
760
+ // one call per chunk where the path has enough subpaths to be worth it
761
+ // — see P_MOVE and _chunkableStroke above — and one for everything
762
+ // else. The chunks are issued from the record, so a stroke that splits
763
+ // never pays for the restore a previous split owed: `_path()` is asked
764
+ // for the surface only on the whole-path route.
765
+ if (!this._strokeChunks(this._s())) {
766
+ this._native.ctxStroke(this._path());
767
+ }
507
768
  this._dirty();
508
769
  }
509
770
 
@@ -515,7 +776,7 @@ export class CocoaContext2D {
515
776
  ) {
516
777
  return;
517
778
  }
518
- this._native.ctxClip(this._s());
779
+ this._native.ctxClip(this._path());
519
780
  }
520
781
 
521
782
  fillRect(x, y, w, h) {
@@ -36,10 +36,28 @@ export class CocoaPaneHost {
36
36
  });
37
37
  }
38
38
 
39
- /** A pane-present landed: scan out of the named shared surface. */
39
+ /**
40
+ * A pane-present landed: scan out of the named shared surface.
41
+ *
42
+ * A present names a buffer the pane may since have retired. The channel
43
+ * is a queue and nothing acknowledges a present, so a pane-rect that
44
+ * changes the pane's size (or its scale — the host window moved to another
45
+ * display during startup, which is how this was first hit) can cross a
46
+ * present already in flight: the pane rebuilds its ring on that rect and
47
+ * releases the old one (`CocoaPaneWindow._ensureSurface`), and by the
48
+ * time the host looks the id up the surface is gone. That present is
49
+ * stale by construction — the pane's full frame on the fresh ring is
50
+ * queued behind it — so it is dropped, and the layer keeps the frame it
51
+ * already holds a reference to. Nothing else here throws; any other
52
+ * error is the bug it says it is.
53
+ */
40
54
  present(iosurfaceId) {
41
55
  if (this.destroyed) return;
42
- this._native.setLayerContentsIOSurface(this.layer, iosurfaceId);
56
+ try {
57
+ this._native.setLayerContentsIOSurface(this.layer, iosurfaceId);
58
+ } catch (err) {
59
+ if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
60
+ }
43
61
  }
44
62
 
45
63
  destroy() {
@@ -50,6 +50,7 @@ export class CocoaPaneWindow {
50
50
  this._dirty = false;
51
51
  this._flushDamage = 'full';
52
52
  this._seq = 0;
53
+ this._presentedAt = -Infinity;
53
54
  this._reactX11Node = null;
54
55
  app._registerWindow(this);
55
56
  }
@@ -98,8 +99,24 @@ export class CocoaPaneWindow {
98
99
  return this.app._requestFrame(cb);
99
100
  }
100
101
 
102
+ /**
103
+ * Whether the host may still be showing the frame before the last one —
104
+ * the gate `flushPendingFrames` (src/frames.js) is written around: a
105
+ * discrete input paints on the spot only when the last frame has landed,
106
+ * which is what folds a burst into one paced frame instead of a frame per
107
+ * event. On X11 the server says when a present was shown; a pane hears
108
+ * nothing back from the host, which flips the layer on its next pump tick
109
+ * and has Core Animation scan it out at the following refresh — so a
110
+ * present counts as in flight for one frame interval. Without a gate here
111
+ * every message the host queued while the pane was busy was answered
112
+ * with a full frame of its own: a forty-tick resize of a pane whose frame
113
+ * costs 300ms stepped through forty sizes for twelve seconds after the
114
+ * drag had ended, each one the previous surface stretched to the layer.
115
+ */
101
116
  frameInFlight() {
102
- return false;
117
+ return (
118
+ performance.now() - this._presentedAt < this.app.frameIntervalFor(null)
119
+ );
103
120
  }
104
121
 
105
122
  // Three buffers, not two. A pane is cross-process: the host keeps
@@ -112,6 +129,28 @@ export class CocoaPaneWindow {
112
129
  // windows need only two because Core Animation latches the front buffer.
113
130
  static RING = 3;
114
131
 
132
+ /**
133
+ * Free the ring now, not when V8 collects the handles — CocoaWindow's
134
+ * `_releaseBacking`, for three buffers instead of two: a host window
135
+ * drag resizes the pane a tick at a time, and each tick retires a ring
136
+ * that the finalizer would have held until a collection happened to run.
137
+ * The host keeps its own reference to whichever buffer its layer shows,
138
+ * so the frame on glass survives the free; a present of a retired buffer
139
+ * still in the channel finds no surface, and the host drops it
140
+ * (`CocoaPaneHost.present`) — the next present is the full frame on the
141
+ * new ring anyway. Bridges before 0.4 have no `releaseSurface`; there the
142
+ * finalizer is still the only owner, and this is the drop it always was.
143
+ */
144
+ _releaseRing() {
145
+ const ring = this._ring;
146
+ this._ring = null;
147
+ this._surface = null;
148
+ if (!ring) return;
149
+ const release = this._native.releaseSurface;
150
+ if (typeof release !== 'function') return;
151
+ for (const s of ring) release.call(this._native, s.handle);
152
+ }
153
+
115
154
  _ensureSurface() {
116
155
  const w = this.width;
117
156
  const h = this.height;
@@ -121,6 +160,7 @@ export class CocoaPaneWindow {
121
160
  this._surfaceSize?.height !== h
122
161
  ) {
123
162
  const hadSurface = Boolean(this._ring);
163
+ this._releaseRing();
124
164
  this._ring = [];
125
165
  for (let i = 0; i < CocoaPaneWindow.RING; i += 1) {
126
166
  const s = this._native.createSurfaceIOSurface(w, h, this.scale, true);
@@ -134,12 +174,9 @@ export class CocoaPaneWindow {
134
174
  this._surfaceSize = { width: w, height: h };
135
175
  this._surfaceGen++;
136
176
  this._flushDamage = 'full';
137
- if (hadSurface) {
138
- queueMicrotask(() => {
139
- const node = this._reactX11Node;
140
- if (node && !node.destroyed) node.invalidate(true, null, 'resize');
141
- });
142
- }
177
+ // decided when the flush reports its rects — see CocoaWindow's
178
+ // `_ensureSurface` for why not a queued full frame from here
179
+ if (hadSurface) this._freshSurface = true;
143
180
  }
144
181
  return this._surface;
145
182
  }
@@ -163,6 +200,20 @@ export class CocoaPaneWindow {
163
200
  }
164
201
 
165
202
  noteFrameDamage(rects) {
203
+ if (this._freshSurface) {
204
+ this._freshSurface = false;
205
+ // a bounded flush onto a fresh ring leaves garbage outside its rects:
206
+ // one full frame, and no present until it lands (CocoaWindow's rule)
207
+ if (rects) {
208
+ this._holdPresent = true;
209
+ const node = this._reactX11Node;
210
+ if (node && !node.destroyed) node.invalidate(false, null, 'resize');
211
+ } else {
212
+ this._holdPresent = false;
213
+ }
214
+ } else if (!rects) {
215
+ this._holdPresent = false;
216
+ }
166
217
  if (this._flushDamage === 'full') return;
167
218
  if (!rects) {
168
219
  this._flushDamage = 'full';
@@ -190,6 +241,7 @@ export class CocoaPaneWindow {
190
241
  /** Flip and tell the host, instead of touching any layer of our own. */
191
242
  present() {
192
243
  if (!this._dirty || !this._ring || this.destroyed) return;
244
+ if (this._holdPresent) return;
193
245
  this._dirty = false;
194
246
  const shown = this._ring[this._drawIndex];
195
247
  this._native.surfaceUnlock(shown.handle);
@@ -202,6 +254,7 @@ export class CocoaPaneWindow {
202
254
  width: this.width,
203
255
  height: this.height,
204
256
  });
257
+ this._presentedAt = performance.now();
205
258
  this._shownIndex = this._drawIndex;
206
259
  // the next buffer round the ring — two behind what the host will be
207
260
  // showing, so it is safe to write even before the host has switched
@@ -230,7 +283,6 @@ export class CocoaPaneWindow {
230
283
  if (this.destroyed) return;
231
284
  this.destroyed = true;
232
285
  this.app._unregisterWindow(this);
233
- this._ring = null;
234
- this._surface = null;
286
+ this._releaseRing();
235
287
  }
236
288
  }