react-x11 2.17.0 → 2.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.17.0",
3
+ "version": "2.17.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/cocoa/app.js CHANGED
@@ -41,7 +41,7 @@ import { CocoaFontManager } from './fonts.js';
41
41
  import { releaseImageUpload } from '../backend/context2d.js';
42
42
  import { CocoaSurface } from './surface.js';
43
43
  import { CocoaSymbols } from './symbols.js';
44
- import { CocoaWindow } from './window.js';
44
+ import { CocoaWindow, FRAME_SLACK_MS } from './window.js';
45
45
  import { decodeKey, modifierMask } from './keymap.js';
46
46
  import { loadNative } from './native.js';
47
47
  import { requestAppKit, threadedChannel } from './threaded.js';
@@ -55,9 +55,6 @@ import { requestAppKit, threadedChannel } from './threaded.js';
55
55
  // bridge that does not report it: a 60Hz floor every Mac clears.
56
56
  const RAF_INTERVAL_MS = 16;
57
57
  const PUMP_INTERVAL_MS = 8;
58
- // How early a pump tick may take a frame that is not quite due, in ms — the
59
- // drift of a timer, not a fraction of the pump (`_frameDue`).
60
- const FRAME_SLACK_MS = 1;
61
58
  // Threaded mode's live-resize handshake (windowkit/appkit#53): how long
62
59
  // AppKit may hold a resize tick for a frame painted at the new size, in ms.
63
60
  // The edge moves when the delegate returns, so the budget is how long a
@@ -906,6 +903,13 @@ export class CocoaApp {
906
903
  * tick alone still does is pump AppKit's events, which is what
907
904
  * `pumpInterval` stays the cadence of. With no pump at all — threaded
908
905
  * mode, where `_pumpInterval` is Infinity — every frame is one of these.
906
+ *
907
+ * The wait is rounded **up** to whole milliseconds, because `setTimeout`
908
+ * only counts those: a 120Hz panel's 8.333ms gate rounded to 8 fired
909
+ * before the clock would take the frame, which cost a second timer per
910
+ * frame to cover the last third of a millisecond. Rounding up costs the
911
+ * grid nothing — `_frameDue` anchors the next slot to the last one, not
912
+ * to when the frame ran — and lands on the first tick the clock accepts.
909
913
  */
910
914
  _armFrameTimer(wait, now) {
911
915
  if (!(wait > 0 && wait < this._pumpInterval)) return;
@@ -922,7 +926,7 @@ export class CocoaApp {
922
926
  this._tickFrames();
923
927
  this._presentAll();
924
928
  },
925
- Math.max(1, Math.round(wait)),
929
+ Math.max(1, Math.ceil(wait)),
926
930
  );
927
931
  }
928
932
 
@@ -231,6 +231,16 @@ export class CocoaGLArea {
231
231
  return this.parent.requestAnimationFrame(cb);
232
232
  }
233
233
 
234
+ /**
235
+ * The owning window's clock, asked where a gate of `interval` ms lands
236
+ * on its grid (`CocoaWindow.nextFrameAt`) — the swap gate phase-locks to
237
+ * it so the two do not stack. A parent with no clock (a bare stub in a
238
+ * test) answers one interval from now, the old swap-relative wait.
239
+ */
240
+ nextFrameAt(interval) {
241
+ return this.parent.nextFrameAt?.(interval) ?? performance.now() + interval;
242
+ }
243
+
234
244
  getContext(kind, config) {
235
245
  if (kind !== 'opengl' || this.destroyed) return null;
236
246
  if (!this._context) {
@@ -262,7 +272,10 @@ function createGLAreaContext(runtime, area, config) {
262
272
  let back = null;
263
273
  let width = 0;
264
274
  let height = 0;
265
- let gateClosed = false;
275
+ // The moment the swap gate reopens, on the window clock's grid — not a
276
+ // boolean a timer flips, so a frame that arrives a fraction of a
277
+ // millisecond before its own timer still reads the true state.
278
+ let gateOpenAt = -Infinity;
266
279
  let gateTimer = null;
267
280
  let destroyed = false;
268
281
 
@@ -289,7 +302,17 @@ function createGLAreaContext(runtime, area, config) {
289
302
  // one, so the honest gate is one display period per swap — the same
290
303
  // timer-reopened gate the XQuartz CGL flavor uses (no backpressure
291
304
  // exists on either).
292
- ctx.canRender = () => !destroyed && !gateClosed;
305
+ //
306
+ // One period *per frame*, though, not per swap: timed from the swap the
307
+ // gate reopened at `slot + drawCost + period`, which overshoots the
308
+ // window clock's next slot however cheap the frame, and the clock then
309
+ // rounded it up to the slot after — two display periods, a 120Hz panel
310
+ // pinned at 60fps whatever the scene cost (issue #631). Anchored to the
311
+ // slot the frame was due at (`CocoaWindow.nextFrameAt`) the two gates
312
+ // are on one grid: the clock cannot take a frame the gate would refuse,
313
+ // because both are the same instant, and the gate still never lets two
314
+ // frames into one period.
315
+ ctx.canRender = (now = performance.now()) => !destroyed && now >= gateOpenAt;
293
316
 
294
317
  ctx.makeCurrent = () => {
295
318
  if (destroyed) return;
@@ -305,6 +328,26 @@ function createGLAreaContext(runtime, area, config) {
305
328
  runtime.gl.bindFramebuffer(target, fb == null ? (back?.fbo ?? 0) : fb);
306
329
  };
307
330
 
331
+ // A frame refused by the gate has nothing else to wake it, so the gate
332
+ // says when it opens. `setTimeout` counts whole milliseconds and a
333
+ // display period is rarely one (8.333 at 120Hz), so a timer that lands
334
+ // early re-arms rather than announcing a gate that is still shut.
335
+ const armGate = () => {
336
+ if (gateTimer || destroyed) return;
337
+ const wait = gateOpenAt - performance.now();
338
+ gateTimer = setTimeout(
339
+ () => {
340
+ gateTimer = null;
341
+ if (destroyed) return;
342
+ if (performance.now() < gateOpenAt) armGate();
343
+ else ctx.onFrameAvailable?.();
344
+ },
345
+ Math.max(1, Math.ceil(wait)),
346
+ );
347
+ // the timer must not hold the process open for an idle scene
348
+ gateTimer.unref?.();
349
+ };
350
+
308
351
  ctx.SwapBuffers = () => {
309
352
  if (destroyed || !back) return;
310
353
  runtime.gl.flush();
@@ -312,14 +355,9 @@ function createGLAreaContext(runtime, area, config) {
312
355
  const shown = back;
313
356
  back = front;
314
357
  front = shown;
315
- gateClosed = true;
316
- gateTimer = setTimeout(() => {
317
- gateTimer = null;
318
- gateClosed = false;
319
- ctx.onFrameAvailable?.();
320
- }, runtime.frameInterval);
321
- // the timer must not hold the process open for an idle scene
322
- gateTimer.unref?.();
358
+ // one display period from the frame's slot, not from this moment
359
+ gateOpenAt = area.nextFrameAt(runtime.frameInterval);
360
+ armGate();
323
361
  };
324
362
 
325
363
  ctx._resized = () => {
@@ -12,6 +12,11 @@ import { CocoaLayerPresenter } from './presenter.js';
12
12
  import { CocoaPromotion } from './promotion.js';
13
13
 
14
14
  let nextWindowId = 1;
15
+ // The slack a frame clock allows under its interval, in ms: a timer's drift
16
+ // and no more (`CocoaApp._frameDue`, `_frameWait`, `nextFrameAt`). It lives
17
+ // here because the window is the clock — `_rafLast` and `_frameInterval`
18
+ // are its fields — and the app imports it from this side.
19
+ export const FRAME_SLACK_MS = 1;
15
20
  // How long a worker's flip may hold its window's next frame (`_armFence`).
16
21
  // The release is reported once the replacing frame has committed, a
17
22
  // fraction of a millisecond later, and a window must not freeze on a report
@@ -910,6 +915,22 @@ export class CocoaWindow {
910
915
  return this.app._requestFrame(cb, this);
911
916
  }
912
917
 
918
+ /**
919
+ * The earliest moment this window's clock will hand out another frame,
920
+ * for a gate of `interval` ms — the display's period by default, which
921
+ * is the clock's own.
922
+ *
923
+ * It is the grid `_frameDue` decides on, not the wall clock: the anchor
924
+ * is the slot the running frame was *due* at, so the answer does not
925
+ * move with how long that frame took or how late its timer fired. A
926
+ * second gate that wants to compose with this clock rather than be
927
+ * rounded up by it has to land on this grid — which is what a
928
+ * `<glarea>`'s swap gate reads it for (src/cocoa/glarea.js).
929
+ */
930
+ nextFrameAt(interval = this._frameInterval) {
931
+ return this._rafLast + interval - FRAME_SLACK_MS;
932
+ }
933
+
913
934
  /**
914
935
  * Push the backing surface at the WindowServer, if anything drew — and
915
936
  * tell the window node what it cost. The flip itself is cheap, and the
package/src/glnodes.js CHANGED
@@ -376,18 +376,21 @@ export class GlAreaNode extends Node {
376
376
  typeof this.window.requestAnimationFrame === 'function'
377
377
  ? (cb) => this.window.requestAnimationFrame(cb)
378
378
  : (cb) => setImmediate(cb);
379
- schedule(() => {
379
+ // the frame clock's own timestamp, not a fresh reading: a gate that
380
+ // composes with that clock is answered from the same moment it is
381
+ // (`canRender` on the Cocoa surface, issue #631)
382
+ schedule((now) => {
380
383
  this._frameScheduled = false;
381
- this._drawFrame();
384
+ this._drawFrame(now);
382
385
  });
383
386
  }
384
387
 
385
- _drawFrame() {
388
+ _drawFrame(now) {
386
389
  const pacer = this._pacer;
387
390
  pacer.began();
388
391
  let drawn = false;
389
392
  try {
390
- drawn = this._drawFrameNow();
393
+ drawn = this._drawFrameNow(now);
391
394
  } finally {
392
395
  pacer.ended(undefined, drawn);
393
396
  }
@@ -397,14 +400,14 @@ export class GlAreaNode extends Node {
397
400
  }
398
401
 
399
402
  /** The frame itself; true when it drew. */
400
- _drawFrameNow() {
403
+ _drawFrameNow(now) {
401
404
  const gl = this.gl;
402
405
  if (!gl || this.destroyed) return false;
403
406
  const direct = gl.backend === 'direct';
404
407
  // On the direct backend every buffer may still be held by the display,
405
408
  // and drawing into one before it comes back would paint what is on
406
409
  // screen. `onFrameAvailable` asks for this frame again when one frees.
407
- if (direct && gl.canRender && !gl.canRender()) {
410
+ if (direct && gl.canRender && !gl.canRender(now)) {
408
411
  this._frameRefused = true;
409
412
  return false;
410
413
  }