react-x11 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -66,7 +66,8 @@
66
66
  "docs:dev": "npm --prefix website start",
67
67
  "docs:build": "npm --prefix website run build",
68
68
  "docs:test": "npm --prefix website test",
69
- "bench:presenters": "node --import tsx scripts/bench/presenters.js"
69
+ "bench:presenters": "node --import tsx scripts/bench/presenters.js",
70
+ "bench:touched": "node scripts/bench/touched.js"
70
71
  },
71
72
  "repository": {
72
73
  "type": "git",
@@ -90,7 +91,7 @@
90
91
  "yoga-layout": "^3.2.1"
91
92
  },
92
93
  "optionalDependencies": {
93
- "@windowkit/appkit": "^0.3.0",
94
+ "@windowkit/appkit": "^0.4.0",
94
95
  "dbus-native": "^0.15.1",
95
96
  "x11-dri": "^0.7.0"
96
97
  },
package/src/Reconciler.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  flushWindowMaps,
32
32
  flushWindowRestacks,
33
33
  windowAttributes,
34
+ setTextStripBelow,
34
35
  } from './nodes.js';
35
36
  import { hasDropProps } from './dnd.js';
36
37
  import { AppProvider } from './appcontext.js';
@@ -790,6 +791,11 @@ export async function createRoot(options = {}) {
790
791
  // anything from disk.
791
792
  beginCompose(app, rest.compose);
792
793
 
794
+ // Under what size a paragraph is painted as a strip of its ink instead
795
+ // of as glyphs (nodes.js, `TextNode._paintsStrip`): six logical pixels by
796
+ // default, 0 for glyphs at every size.
797
+ setTextStripBelow(app, rest.textStripBelow);
798
+
793
799
  // Whether a subtree coming back out of hiding — a `<Suspense>` boundary
794
800
  // resolving, an `<Activity>` shown again — takes the keyboard back with it
795
801
  // (src/events.js, `subtreeRevealed`). On by default; `false` is the
package/src/cocoa/app.js CHANGED
@@ -29,7 +29,18 @@ import { CocoaWindow } from './window.js';
29
29
  import { decodeKey, modifierMask } from './keymap.js';
30
30
  import { loadNative } from './native.js';
31
31
 
32
+ // The frame interval: how often a scheduled frame may paint, in ms. The
33
+ // default is the period of the display the window is on — `listScreens`
34
+ // reports each panel's `fps` (NSScreen.maximumFramesPerSecond, bridge 0.4),
35
+ // so a 120Hz panel paints every 8.3ms and a 60Hz monitor every 16.7 —
36
+ // and `createRoot({ cocoa: { frameInterval } })` overrides it for every
37
+ // window. 16 stands in where the OS cannot say (`fps: 0`) and under a
38
+ // bridge that does not report it: a 60Hz floor every Mac clears.
32
39
  const RAF_INTERVAL_MS = 16;
40
+ const PUMP_INTERVAL_MS = 8;
41
+ // How early a pump tick may take a frame that is not quite due, in ms — the
42
+ // drift of a timer, not a fraction of the pump (`_frameDue`).
43
+ const FRAME_SLACK_MS = 1;
33
44
 
34
45
  export class CocoaApp {
35
46
  constructor(native, options = {}) {
@@ -37,8 +48,18 @@ export class CocoaApp {
37
48
  this.options = options;
38
49
  this._windows = new Map(); // windowNumber -> CocoaWindow
39
50
  this._grabWindow = null;
40
- this._rafQueue = [];
51
+ this._rafQueue = []; // [{ cb, wnd }]
52
+ // the app's own frame clock, for a frame no window owns (a pane's)
41
53
  this._rafLast = 0;
54
+ // an explicit interval applies to every window; null means each
55
+ // window paces itself on its own display (`frameIntervalFor`)
56
+ this._frameInterval = options.cocoa?.frameInterval ?? null;
57
+ this._pumpInterval = PUMP_INTERVAL_MS;
58
+ // the one-shot that runs a frame due between two pump ticks, and when
59
+ // it is due (`_armFrameTimer`)
60
+ this._frameTimer = null;
61
+ this._frameTimerAt = 0;
62
+ this._geometryFlushQueued = false;
42
63
  this._shadowStale = new Set();
43
64
  this._pump = null;
44
65
  this._closed = false;
@@ -243,6 +264,35 @@ export class CocoaApp {
243
264
  this._windows.set(wnd.windowNumber, wnd);
244
265
  }
245
266
 
267
+ /**
268
+ * How often `wnd` may paint, in ms: the explicit `frameInterval` when the
269
+ * root was given one, else the period of the screen under the window's
270
+ * centre — the display's own rate is the only honest cadence, and on a
271
+ * desk with a 120Hz panel and a 60Hz monitor the two windows differ. A
272
+ * window on no screen (mid-drag between two, or off the edge) and a
273
+ * screen the OS reports no rate for take the primary's, then 16ms.
274
+ */
275
+ frameIntervalFor(wnd) {
276
+ if (this._frameInterval != null) return this._frameInterval;
277
+ const s = this.scale;
278
+ const screens = this._screens ?? [];
279
+ let screen = null;
280
+ if (wnd) {
281
+ const cx = (wnd.x + wnd.width / 2) / s;
282
+ const cy = (wnd.y + wnd.height / 2) / s;
283
+ screen =
284
+ screens.find(
285
+ (sc) =>
286
+ cx >= sc.x &&
287
+ cx < sc.x + sc.width &&
288
+ cy >= sc.y &&
289
+ cy < sc.y + sc.height,
290
+ ) ?? null;
291
+ }
292
+ const fps = screen?.fps || screens[0]?.fps || 0;
293
+ return fps > 0 ? 1000 / fps : RAF_INTERVAL_MS;
294
+ }
295
+
246
296
  /**
247
297
  * The pane's end of the frame channel (childmain hands it over,
248
298
  * feature-detected so the X11 pane path never notices): geometry and
@@ -277,8 +327,9 @@ export class CocoaApp {
277
327
 
278
328
  // --- the pump ------------------------------------------------------------
279
329
 
280
- start({ pumpInterval = 8 } = {}) {
330
+ start({ pumpInterval = PUMP_INTERVAL_MS } = {}) {
281
331
  if (this._pump) return;
332
+ this._pumpInterval = pumpInterval;
282
333
  const native = this._native;
283
334
  // A pane process has no NSApplication to pump — no windows, no events,
284
335
  // no dock presence. Its loop is frames and presents only.
@@ -292,6 +343,7 @@ export class CocoaApp {
292
343
  native.initApp();
293
344
  native.setBackendEventCallback((ev) => this._route(ev));
294
345
  this._pump = setInterval(() => {
346
+ this._endLiveResizes();
295
347
  native.pump2(); // flushes the previous tick's CATransaction
296
348
  if (this._shadowStale.size) {
297
349
  for (const wnd of this._shadowStale) {
@@ -304,27 +356,129 @@ export class CocoaApp {
304
356
  }, pumpInterval);
305
357
  }
306
358
 
307
- _requestFrame(cb) {
308
- this._rafQueue.push(cb);
359
+ /**
360
+ * A pump tick means no modal loop owns the thread, so no window is being
361
+ * resized live right now: the flag the delegate set on the last tick of a
362
+ * drag comes off here, ahead of the frames that tick, and the catch-up
363
+ * frame a deferred layout owes (nodes.js) runs on this very tick.
364
+ */
365
+ _endLiveResizes() {
366
+ for (const wnd of this._windows.values()) wnd.liveResizing = false;
367
+ }
368
+
369
+ _requestFrame(cb, wnd = null) {
370
+ this._rafQueue.push({ cb, wnd });
309
371
  return this._rafQueue.length;
310
372
  }
311
373
 
374
+ /**
375
+ * Whether `clock` — a window, or the app for a frame no window owns —
376
+ * is due a frame at `now`, and if so, stamps it.
377
+ *
378
+ * The clock keeps the display's period, not the pump's: a frame that ran
379
+ * is stamped one interval after the last one was due rather than at the
380
+ * moment it happened to run, so a tick that took it a little early or a
381
+ * timer that fired a little late moves nothing — the next frame is still
382
+ * due where the display's next refresh is. A frame that arrives more than
383
+ * an interval late (a slow one) re-anchors the clock instead of owing the
384
+ * frames it missed.
385
+ *
386
+ * The gate is a millisecond of slack under the interval, which is a
387
+ * timer's drift and no more. It used to be half a pump, so that a frame
388
+ * would land on the first tick at or after the interval instead of
389
+ * alternating between the tick before and the one after; that quantized
390
+ * the period to the pump — a 75Hz monitor's 13.3ms gate of 9.3 fell on
391
+ * the 16ms tick, and the window painted at 62.5fps. A frame due between
392
+ * two ticks now gets a timer of its own (`_armFrameTimer`), and the
393
+ * period is the display's whatever the pump's cadence.
394
+ */
395
+ _frameDue(clock, now) {
396
+ const interval =
397
+ clock === this ? this.frameIntervalFor(null) : clock._frameInterval;
398
+ const since = now - clock._rafLast;
399
+ if (since < interval - FRAME_SLACK_MS) return false;
400
+ clock._rafLast = since < 2 * interval ? clock._rafLast + interval : now;
401
+ return true;
402
+ }
403
+
404
+ /** How long until `clock` is due, from `now`. */
405
+ _frameWait(clock, now) {
406
+ const interval =
407
+ clock === this ? this.frameIntervalFor(null) : clock._frameInterval;
408
+ return interval - FRAME_SLACK_MS - (now - clock._rafLast);
409
+ }
410
+
411
+ /**
412
+ * A frame that falls between two pump ticks gets a tick of its own: a
413
+ * one-shot timer at the moment it is due, which runs the frame queue and
414
+ * presents what it painted. Only when the next pump tick would be late
415
+ * for it — a frame due after that tick waits for the tick, which decides
416
+ * again. One timer at a time, at the soonest of what is owed; a pump tick
417
+ * that comes first runs whatever is due and re-arms for the rest.
418
+ *
419
+ * The timer costs the frame nothing the tick does not: a present commits
420
+ * its own transaction (the bridge flushes on the flip), so what is
421
+ * painted here is on glass without waiting for the next pump. What the
422
+ * tick alone still does is pump AppKit's events, which is what
423
+ * `pumpInterval` stays the cadence of.
424
+ */
425
+ _armFrameTimer(wait, now) {
426
+ if (!(wait > 0 && wait < this._pumpInterval)) return;
427
+ const at = now + wait;
428
+ if (this._frameTimer) {
429
+ if (at >= this._frameTimerAt) return;
430
+ clearTimeout(this._frameTimer);
431
+ }
432
+ this._frameTimerAt = at;
433
+ this._frameTimer = setTimeout(
434
+ () => {
435
+ this._frameTimer = null;
436
+ if (this._closed) return;
437
+ this._tickFrames();
438
+ this._presentAll();
439
+ },
440
+ Math.max(1, Math.round(wait)),
441
+ );
442
+ }
443
+
312
444
  _tickFrames() {
313
445
  if (!this._rafQueue.length) return;
314
- const now = Date.now();
315
- if (now - this._rafLast < RAF_INTERVAL_MS) return;
316
- this._rafLast = now;
446
+ const now = performance.now();
447
+ // Each window keeps its own clock, so a window on a 120Hz panel paints
448
+ // every refresh while one on a 60Hz monitor paints every other pump
449
+ // tick. Decided once per clock per tick: every frame a window queued
450
+ // runs when it is due, not just the first.
451
+ const due = new Map();
317
452
  const queue = this._rafQueue;
318
453
  this._rafQueue = [];
319
- for (const cb of queue) {
454
+ let soonest = Infinity;
455
+ for (const entry of queue) {
456
+ const clock = entry.wnd ?? this;
457
+ if (!due.has(clock)) due.set(clock, this._frameDue(clock, now));
458
+ if (!due.get(clock)) {
459
+ this._rafQueue.push(entry);
460
+ soonest = Math.min(soonest, this._frameWait(clock, now));
461
+ continue;
462
+ }
463
+ // A window nobody can see owes no frame: its callback waits here until
464
+ // it is back on glass, and the damage it answers accumulates on the
465
+ // node — one catch-up frame then, instead of a full paint per tick
466
+ // into a backing store no one reads. `_visible` is the window's own
467
+ // rule (mapped, not ordered out, not miniaturized, not entirely
468
+ // behind another application's window).
469
+ if (entry.wnd && !entry.wnd._visible()) {
470
+ this._rafQueue.push(entry);
471
+ continue;
472
+ }
320
473
  try {
321
- cb(now);
474
+ entry.cb(now);
322
475
  } catch (err) {
323
476
  queueMicrotask(() => {
324
477
  throw err;
325
478
  });
326
479
  }
327
480
  }
481
+ this._armFrameTimer(soonest, now);
328
482
  }
329
483
 
330
484
  _presentAll() {
@@ -366,6 +520,8 @@ export class CocoaApp {
366
520
  return this._routeFocus(ev, 'focus');
367
521
  case 'window-blur':
368
522
  return this._routeFocus(ev, 'blur');
523
+ case 'window-occlusion':
524
+ return this._routeOcclusion(ev);
369
525
  case 'menu-activate':
370
526
  this._activeGlobalMenu?.activate(ev.id);
371
527
  return this._afterInput();
@@ -459,6 +615,13 @@ export class CocoaApp {
459
615
  smooth: Boolean(ev.precise),
460
616
  source: ev.precise ? 'valuator' : 'button',
461
617
  });
618
+ // Painted now, like a press. On X11 the wheel is paced on the frame
619
+ // clock because ntk coalesces a touchpad's dozens of reports per frame
620
+ // into one event; AppKit already delivers scroll events at the
621
+ // display's rate, so answering each one is answering once per refresh
622
+ // — and answering it on the next frame tick instead was a 15ms median
623
+ // between the notch and the scroll, most of a refresh period of nothing.
624
+ this._afterInput();
462
625
  }
463
626
 
464
627
  _routeKey(ev) {
@@ -499,9 +662,31 @@ export class CocoaApp {
499
662
  // handler returns, and the pump that would paint it is the thing the
500
663
  // modal loop stalled. A second flush queued BEHIND that commit is what
501
664
  // lets an open menu resize with the drag instead of on release.
502
- queueMicrotask(() => {
503
- if (!this._closed) this._afterInput();
504
- });
665
+ //
666
+ // One, not one per event: inside the modal loop no microtask runs until
667
+ // the drag ends, so a drag's every tick queued another — and they all
668
+ // ran on the release, each finding the frame the previous one had just
669
+ // paid. Coalesced, the release owes at most one flush.
670
+ if (!this._geometryFlushQueued) {
671
+ this._geometryFlushQueued = true;
672
+ queueMicrotask(() => {
673
+ this._geometryFlushQueued = false;
674
+ if (!this._closed) this._afterInput();
675
+ });
676
+ }
677
+ }
678
+
679
+ /**
680
+ * `windowDidChangeOcclusionState`: `visible` is "some pixel of the window
681
+ * is on glass". Off, the window's frames wait in the queue and its
682
+ * present waits with them (`CocoaWindow._visible`); on, the next pump
683
+ * tick runs the catch-up frame and puts it on glass — no early flush,
684
+ * since nothing was asked for and the pump is at most one interval away.
685
+ */
686
+ _routeOcclusion(ev) {
687
+ const wnd = this._window(ev);
688
+ if (!wnd || wnd.destroyed) return;
689
+ wnd._occluded = ev.visible === false;
505
690
  }
506
691
 
507
692
  _routeClose(ev) {
@@ -528,6 +713,8 @@ export class CocoaApp {
528
713
  this._closed = true;
529
714
  if (this._pump) clearInterval(this._pump);
530
715
  this._pump = null;
716
+ if (this._frameTimer) clearTimeout(this._frameTimer);
717
+ this._frameTimer = null;
531
718
  this._cocoaGL?.destroy();
532
719
  this._cocoaGL = null;
533
720
  this._native.setBackendEventCallback(null);
@@ -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 {
@@ -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
  }