react-x11 2.49.0 → 2.51.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.
@@ -21,9 +21,85 @@ import {
21
21
  containerAnswers,
22
22
  resolveQueries,
23
23
  } from '../styles.js';
24
+ import { readLength, UNIT_EM, UNIT_REM, UNIT_VIEWPORT } from '../shorthands.js';
25
+ import { DEFAULT_TEXT_STYLE } from '../styles.js';
24
26
  import { CUSTOM_SEMANTIC_NAMES } from './kinds.js';
25
27
  import { DEV, shallowEqual } from './util.js';
26
28
 
29
+ /**
30
+ * What a relative length on one node resolves against — `em`, `rem` and the
31
+ * viewport units (src/shorthands.js, `RELATIVE`) — in logical pixels, the
32
+ * unit everything an app writes is in. Lazy: a style written in pixels asks
33
+ * none of it, and pays one allocation. Each answer it gives is recorded in
34
+ * `deps`, which is how the node knows to resolve the style again when that
35
+ * answer moves (`_noteUnits`).
36
+ *
37
+ * `style` is the one being resolved, not the one in force: a `padding:
38
+ * '1em'` beside a `fontSize: '20px'` is 20, whichever the style had before.
39
+ */
40
+ class Units {
41
+ constructor(node, style) {
42
+ this.node = node;
43
+ this.style = style;
44
+ this.deps = 0;
45
+ this.parentEmRead = undefined;
46
+ this.emRead = undefined;
47
+ }
48
+
49
+ /** The font size this node inherits — what `em` is on `fontSize`
50
+ * itself, as in CSS. Device pixels down the cascade, at this node's
51
+ * scale, so one divide brings it back. */
52
+ get parentEm() {
53
+ this.deps |= UNIT_EM;
54
+ if (this.parentEmRead === undefined) {
55
+ const node = this.node;
56
+ this.parentEmRead = node.inheritedTextStyle.size / (node.scale || 1);
57
+ }
58
+ return this.parentEmRead;
59
+ }
60
+
61
+ /** This node's own font size — its `fontSize`, read against what it
62
+ * inherits where that is relative, or the inherited one. Depends on the
63
+ * inheritance only when it reads it. */
64
+ get em() {
65
+ if (this.emRead === undefined) {
66
+ const size = this.style.fontSize;
67
+ let own;
68
+ if (size != null && size !== '') {
69
+ try {
70
+ own = readLength('fontSize', size, undefined, this);
71
+ } catch {
72
+ // the text inherits past a size it cannot read, and so does `em`
73
+ }
74
+ }
75
+ this.emRead = typeof own === 'number' && own >= 0 ? own : this.parentEm;
76
+ }
77
+ return this.emRead;
78
+ }
79
+
80
+ /** The palette's `fontSize`: the root font size here, the size every label
81
+ * that names none of its own is set in. */
82
+ get rem() {
83
+ this.deps |= UNIT_REM;
84
+ return this.node.theme?.fontSize ?? DEFAULT_TEXT_STYLE.size;
85
+ }
86
+
87
+ /** The window this node is in, as a size query sees it. Before the window
88
+ * has been laid out, the size it asked for, where it named one; the
89
+ * size-query registry resolves the style again when the real one lands. */
90
+ get viewport() {
91
+ this.deps |= UNIT_VIEWPORT;
92
+ const node = this.node;
93
+ const root = node.root ?? (node.isWindow ? node : null);
94
+ if (root?.querySize) return root.querySize;
95
+ const props = root?.props;
96
+ return {
97
+ width: typeof props?.width === 'number' ? props.width : 0,
98
+ height: typeof props?.height === 'number' ? props.height : 0,
99
+ };
100
+ }
101
+ }
102
+
27
103
  /**
28
104
  * A style property passed flat used to be silently dropped — it was neither
29
105
  * a layout prop, a paint prop nor an `on*` handler, so nothing looked at it
@@ -46,6 +122,9 @@ function assertNoFlatStyleProps(props, kind, semantic) {
46
122
  // style, and there is no element where a name means both.
47
123
  const NO_SEMANTIC_NAMES = new Set();
48
124
 
125
+ /** What a style that read no relative length read: nothing. */
126
+ const NO_UNITS = Object.freeze({ deps: 0, parentEmRead: undefined });
127
+
49
128
  /** A node's own style, installed onto `Node.prototype` by node.js. */
50
129
  export class NodeStyling {
51
130
  /**
@@ -81,15 +160,9 @@ export class NodeStyling {
81
160
  // window size queries fold into the base before state blocks, so a
82
161
  // `:hover` inside the wide layout still wins over the wide layout
83
162
  const kinds = queryKinds(this._baseStyle);
163
+ // whether a resize re-resolves this node is decided at the end, once the
164
+ // style has said whether it reads the window through a viewport unit
84
165
  const queried = (kinds & QUERY_SIZE) !== 0;
85
- if (queried !== this._queried) {
86
- this._queried = queried;
87
- const root = this.root;
88
- if (root?._sizeQueryNodes) {
89
- if (queried) root._sizeQueryNodes.add(this);
90
- else root._sizeQueryNodes.delete(this);
91
- }
92
- }
93
166
  // `@supports` blocks keep their own registry: what re-resolves them is
94
167
  // the server's answer changing, not a resize
95
168
  // Registered unconditionally rather than on change: a `<window>`'s own
@@ -166,20 +239,90 @@ export class NodeStyling {
166
239
  }
167
240
  }
168
241
  this._stateful = hasStateStyles(this._baseStyle);
242
+ const merged = this._stateful
243
+ ? resolveStyleStates(this._baseStyle, this.states)
244
+ : this._baseStyle;
169
245
  // The scale multiplies *after* every merge — state blocks, queries,
170
- // the flex shorthand — so each of those keeps thinking in the logical
171
- // pixels the app wrote, and device pixels exist only downstream of
172
- // this line (src/scale.js).
173
- return this._retarget(
174
- scaleResolvedStyle(
175
- resolveComputedStyle(
176
- this._stateful
177
- ? resolveStyleStates(this._baseStyle, this.states)
178
- : this._baseStyle,
179
- ),
180
- this.scale,
181
- ),
246
+ // the shorthands, the relative units — so each of those keeps thinking
247
+ // in the logical pixels the app wrote, and device pixels exist only
248
+ // downstream of this line (src/scale.js).
249
+ this._unitsNow = null;
250
+ const target = scaleResolvedStyle(
251
+ resolveComputedStyle(merged, this._unitsMaker()),
252
+ this.scale,
253
+ );
254
+ const units = this._unitsNow ?? NO_UNITS;
255
+ // what a loop's relative end reads (`_syncLoops`) is read through the
256
+ // same context, during the swap
257
+ // …and the basis is in place before the swap, which may walk the text
258
+ // cascade back through this node (`_emMoved`)
259
+ this._units = units;
260
+ this._noteUnits(units, queried, false);
261
+ const out = this._retarget(target);
262
+ this._units = null;
263
+ this._noteUnits(units, queried, true);
264
+ return out;
265
+ }
266
+
267
+ /**
268
+ * Record what this node's style read to resolve its relative lengths, and
269
+ * join or leave what moves each: the size-query registry for the window's
270
+ * size (a viewport unit asks the same question an `'@width'` block does,
271
+ * and is answered by the same re-resolution on a resize); the text cascade
272
+ * for the inherited font size (`_retext` walks it, and stops at a node
273
+ * whose resolution was never cached — so a node that reads it caches one);
274
+ * and the theme walk for the palette's (`_themeChanged`).
275
+ */
276
+ /** What makes this node's unit context, for `resolveComputedStyle` to
277
+ * call only when a style has something to read; the one it made is
278
+ * `_unitsNow`. Made once per node, rather than a context per style. */
279
+ _unitsMaker() {
280
+ return (this._makeUnits ??= (style) =>
281
+ (this._unitsNow = new Units(this, style)));
282
+ }
283
+
284
+ _noteUnits(units, sizeQueried, settled) {
285
+ this._unitDeps = units.deps;
286
+ this._emBasis = units.parentEmRead;
287
+ this._sizeQueried = sizeQueried;
288
+ const asks = sizeQueried || (units.deps & UNIT_VIEWPORT) !== 0;
289
+ if (asks !== this._queried) {
290
+ this._queried = asks;
291
+ const root = this.root;
292
+ if (root?._sizeQueryNodes) {
293
+ if (asks) root._sizeQueryNodes.add(this);
294
+ else root._sizeQueryNodes.delete(this);
295
+ }
296
+ }
297
+ // asked of the style the swap put in force, never the one before it
298
+ if (settled && units.deps & UNIT_EM && this._resolvedText === undefined) {
299
+ this.resolvedTextStyle();
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Resolve the style again because something a relative length read moved
305
+ * — the font size this node inherits — with no commit to do it. The rest
306
+ * is the tail a re-resolution outside a commit always has
307
+ * (`_restyled`), plus the claim a commit would have made.
308
+ */
309
+ _unitsMoved() {
310
+ if (this.destroyed) return;
311
+ const before = this.style;
312
+ this._syncStyle(this.props);
313
+ const moved = this._restyled(
314
+ before,
315
+ 'resolved against a font size that moved',
182
316
  );
317
+ if (moved) this._invalidateLayout('units');
318
+ else if (this.style !== before) this.root?.invalidate(false, this, 'units');
319
+ }
320
+
321
+ /** Whether the font size this node inherits is not the one its relative
322
+ * lengths were resolved against. */
323
+ _emMoved() {
324
+ if (!(this._unitDeps & UNIT_EM)) return false;
325
+ return this.inheritedTextStyle.size / (this.scale || 1) !== this._emBasis;
183
326
  }
184
327
 
185
328
  /**
@@ -194,12 +337,24 @@ export class NodeStyling {
194
337
  // the node has a state block of its own (`_outlineExtent`)
195
338
  this._clearPaintBounds();
196
339
  if (!this._stateful || this.destroyed) return;
340
+ const merged = resolveStyleStates(this._baseStyle, this.states);
341
+ this._unitsNow = null;
197
342
  const next = scaleResolvedStyle(
198
- resolveComputedStyle(resolveStyleStates(this._baseStyle, this.states)),
343
+ resolveComputedStyle(merged, this._unitsMaker()),
199
344
  this.scale,
200
345
  );
201
346
  if (shallowEqual(next, this._targetStyle)) return;
347
+ const units = this._unitsNow;
348
+ this._units = units;
202
349
  this._retarget(next);
350
+ this._units = null;
351
+ // a state block reads what the base did and may read more — a radius in
352
+ // `rem` on `:hover` — and a node keeps answering to all of it
353
+ if (units !== null) {
354
+ units.deps |= this._unitDeps;
355
+ units.parentEmRead ??= this._emBasis;
356
+ this._noteUnits(units, this._sizeQueried, true);
357
+ }
203
358
  // a state block may only set paint properties, so the node's own region
204
359
  // is the whole of what changed
205
360
  this.root?.invalidate(false, this, 'style-state');
@@ -137,7 +137,7 @@ export class WindowCapabilities {
137
137
  * whatever it skipped would show the last frame's pixels.
138
138
  */
139
139
  _windowBackground() {
140
- const own = this.style.backgroundColor;
140
+ const own = this.resolveColor(this.style.backgroundColor);
141
141
  if (isColor(own)) return own;
142
142
  const palette = this.theme.background;
143
143
  return isColor(palette) ? palette : 'white';
@@ -233,24 +233,24 @@ export function collectFloorStale(node, stale, found, sweep) {
233
233
  }
234
234
 
235
235
  /**
236
- * Whether `node` is a box its content cannot size and its surroundings cannot
237
- * size either — an edge a floor measurement may stop at
236
+ * Whether `node` is a box its surroundings cannot size and nothing around it
237
+ * reads the size of — an edge a floor measurement may stop at
238
238
  * (`WindowNode._floorsScope`).
239
239
  *
240
- * Absolutely positioned, with a width and a height its style names as
241
- * numbers: it is out of its parent's flow, so nothing measured inside it
242
- * reaches anything above it (`contentSpan` skips it, and no floor is written
243
- * on it), and every pass lays it out at those numbers wherever it sits, so
244
- * what is inside it is measured the same in a pass over the window and in a
245
- * pass over it alone. Nothing in its own box resolves against its parent —
246
- * a percentage of padding would — and neither it nor its parent is a layout
247
- * host, whose children are measured tree by tree.
240
+ * Absolutely positioned, and the same size in every pass wherever it sits
241
+ * (`sizedAlike`): it is out of its parent's flow, so nothing measured inside
242
+ * it reaches anything above it (`contentSpan` skips it, and no floor is
243
+ * written on it), and what is inside it is measured the same in a pass over
244
+ * the window and in a pass over it alone. Nothing in its own box resolves
245
+ * against its parent — a percentage of padding would — and neither it nor
246
+ * its parent is a layout host, whose children are measured tree by tree.
248
247
  *
249
- * A card on a graph, a popover laid out inside the window, a sized overlay:
250
- * the boxes a change inside is most often confined to. An in-flow box with a
251
- * size of its own would do as well along its cross axis, but on its main
252
- * axis a flex line can grow it past its width or shrink it under a minimum
253
- * the author named, and that is a question about its siblings.
248
+ * A card on a graph, a popover laid out inside the window, a sized overlay,
249
+ * a badge or a label hung off a point: the boxes a change inside is most
250
+ * often confined to. An in-flow box with a size of its own would do as well
251
+ * along its cross axis, but on its main axis a flex line can grow it past
252
+ * its width or shrink it under a minimum the author named, and that is a
253
+ * question about its siblings.
254
254
  */
255
255
  export function isFloorBoundary(node) {
256
256
  const style = node.style;
@@ -259,18 +259,63 @@ export function isFloorBoundary(node) {
259
259
  !node.isWindow &&
260
260
  style.position === 'absolute' &&
261
261
  style.display !== 'none' &&
262
- typeof style.width === 'number' &&
263
- typeof style.height === 'number' &&
264
262
  typeof style.minWidth !== 'string' &&
265
263
  typeof style.minHeight !== 'string' &&
266
264
  typeof style.maxWidth !== 'string' &&
267
265
  typeof style.maxHeight !== 'string' &&
268
266
  !PADDINGS.some((key) => typeof style[key] === 'string') &&
269
267
  node._host === null &&
270
- (node.parent?._host ?? null) === null
268
+ (node.parent?._host ?? null) === null &&
269
+ sizedAlike(node, 'width') &&
270
+ sizedAlike(node, 'height')
271
271
  );
272
272
  }
273
273
 
274
+ /**
275
+ * Whether every pass lays this absolute box out at the same size along
276
+ * `axis`, whatever its containing block is offered: a number its style
277
+ * names, or the size of what is inside it.
278
+ *
279
+ * Yoga measures an absolute box that names no size at its content's
280
+ * max-content size, with no bound from the box it is placed in — down, and
281
+ * across under a row. Under a column it is offered the column's width to
282
+ * wrap in, and that is another number in each pass (none in the width
283
+ * pass, the real one in the heights'), so what is inside it would be
284
+ * measured at whatever width the pass gave it, and not at the one a pass
285
+ * over it alone does. Nor may anything else bound it on that axis: an edge
286
+ * pinned at both ends sizes it from its containing block, a ratio from the
287
+ * other axis, and a minimum or a maximum is applied after its content was
288
+ * measured — yoga lays out the height of a box held under a `maxWidth` at
289
+ * the width it had before it was held, and a box laid out alone at the
290
+ * width it was held to.
291
+ */
292
+ function sizedAlike(node, axis) {
293
+ const style = node.style;
294
+ const across = axis === 'width';
295
+ const size = across ? style.width : style.height;
296
+ if (typeof size === 'number') return true;
297
+ if (size !== undefined && size !== 'auto') return false;
298
+ if (
299
+ (across ? style.minWidth : style.minHeight) !== undefined ||
300
+ (across ? style.maxWidth : style.maxHeight) !== undefined ||
301
+ style.aspectRatio !== undefined
302
+ ) {
303
+ return false;
304
+ }
305
+ // two insets across are both ends in one direction or the other
306
+ let insets = 0;
307
+ for (const key of across ? INSETS_ACROSS : INSETS_DOWN) {
308
+ if (style[key] !== undefined) insets++;
309
+ }
310
+ if (insets > 1) return false;
311
+ return (
312
+ !across || (node.parent != null && mainAxisOf(node.parent) === 'width')
313
+ );
314
+ }
315
+
316
+ const INSETS_ACROSS = ['left', 'right', 'start', 'end'];
317
+ const INSETS_DOWN = ['top', 'bottom'];
318
+
274
319
  const PADDINGS = [
275
320
  'padding',
276
321
  'paddingTop',
@@ -2,7 +2,11 @@
2
2
  // floors, laying the root out, deferring floors during a live resize and
3
3
  // catching up after, and refitting the window to what it holds.
4
4
 
5
- import { exactLayoutConfig, measuringExactly } from '../../styles.js';
5
+ import {
6
+ exactLayoutConfig,
7
+ measuringExactly,
8
+ setLayoutValue,
9
+ } from '../../styles.js';
6
10
  import { availableArea } from '../../screens.js';
7
11
  import { Yoga } from '../../yoga.js';
8
12
  import {
@@ -185,7 +189,14 @@ export class WindowSize {
185
189
  this._floorsStale.add(boundary.parent);
186
190
  }
187
191
  // its children's floors are written from here; a sized box's own
188
- // extent is its style's, and stands
192
+ // extent is its style's, and stands, and one its content sizes is
193
+ // measured with them (`_ownExtent`), or left unmeasured with them
194
+ if (typeof boundary.style.width !== 'number') {
195
+ boundary._floorW = undefined;
196
+ }
197
+ if (typeof boundary.style.height !== 'number') {
198
+ boundary._floorH = undefined;
199
+ }
189
200
  this._floorsStale.add(boundary);
190
201
  collectFloorStale(boundary, this._floorsStale, found, false);
191
202
  }
@@ -453,9 +464,10 @@ export class WindowSize {
453
464
  /**
454
465
  * One boundary's floors, measured with it laid out alone — the passes
455
466
  * `_measureContentSpans` runs over the whole tree, run over the box: its
456
- * size is its style's in either, so the space everything inside it is
457
- * laid out in is the same. `probe` is the height measurement's check for
458
- * leaves whose width moved, stopped at the box, whose own extent stands.
467
+ * size is the same in either (`isFloorBoundary`), its style's or its
468
+ * content's, so the space everything inside it is laid out in is the
469
+ * same. `probe` is the height measurement's check for leaves whose width
470
+ * moved, stopped at the box, whose own extent stands.
459
471
  */
460
472
  _measureBoundary(boundary, axis, probe = false) {
461
473
  // on copies of its boxes (exactcopy.js): the tree's own are left as the
@@ -473,45 +485,108 @@ export class WindowSize {
473
485
  if (axis === 'width') {
474
486
  const shrunk = [];
475
487
  setMeasuringShrink(boundary, axis, shrunk);
476
- // its own extent is its style's, so what it holds is read only where
477
- // a floor is written from it, as down the window's columns
478
- const read = NO_UNREAD_WIDTHS || this._readEveryWidth;
488
+ // nothing reads its own extent, so what it holds is read only where a
489
+ // floor is written from it, as down the window's columns — where its
490
+ // width is its style's: one that is its content's is as wide as the
491
+ // leaves in it, stretched or not, and a leaf left unshaped answers 0
492
+ const read =
493
+ NO_UNREAD_WIDTHS ||
494
+ this._readEveryWidth ||
495
+ typeof boundary.style.width !== 'number';
479
496
  const unread = [];
480
497
  if (!read) markUnreadLeaves(boundary, unread);
481
- this._scopedFloorPasses += 1;
482
498
  try {
483
- yoga.calculateLayout(undefined, undefined, dir);
499
+ this._layOutBoundary(boundary, undefined, dir);
484
500
  } finally {
485
501
  for (const leaf of unread) leaf._widthUnread = false;
486
502
  }
487
- contentSpan(boundary, axis, null, this, read);
503
+ const span = contentSpan(boundary, axis, null, this, read);
504
+ if (typeof boundary.style.width !== 'number') {
505
+ this._ownExtent(boundary, axis, span);
506
+ }
488
507
  restoreShrink(shrunk);
489
508
  return;
490
509
  }
491
- this._scopedFloorPasses += 1;
492
- yoga.calculateLayout(undefined, undefined, dir);
510
+ this._layOutBoundary(boundary, undefined, dir);
493
511
  if (probe) {
494
512
  const hit = { marked: false, owed: false };
495
513
  probeHeightFloors(boundary, this, hit, boundary);
496
514
  if (hit.marked) {
497
515
  this._writeFloors('height');
498
- this._scopedFloorPasses += 1;
499
- yoga.calculateLayout(undefined, undefined, dir);
516
+ this._layOutBoundary(boundary, undefined, dir);
500
517
  }
501
518
  }
502
519
  const intrinsic = new Map();
503
520
  captureLeafHeights(boundary, intrinsic);
504
- const frozen = [];
521
+ // …and the box's own, as the pass over the window pins it with
522
+ // everything else under a node being measured: one its content sizes
523
+ // would be measured again in the collapse, and a row that wraps take
524
+ // every item onto one line
525
+ const frozen = [boundary];
526
+ yoga.setWidth(yoga.getComputedWidth());
505
527
  freezeWidths(boundary, frozen);
506
528
  const shrunk = [];
507
529
  setMeasuringShrink(boundary, axis, shrunk);
508
- this._scopedFloorPasses += 1;
509
- yoga.calculateLayout(undefined, 0, dir);
510
- contentSpan(boundary, axis, intrinsic, this);
530
+ this._layOutBoundary(boundary, 0, dir, true);
531
+ const span = contentSpan(boundary, axis, intrinsic, this);
532
+ if (typeof boundary.style.height !== 'number') {
533
+ this._ownExtent(boundary, axis, span);
534
+ }
511
535
  restoreWidths(frozen);
512
536
  restoreShrink(shrunk);
513
537
  }
514
538
 
539
+ /** A boundary its content sizes has an extent its content gives, as any
540
+ * box does (`contentSpan`): nothing reads it, absolute as the box is, and
541
+ * it is written here as the pass over the window would have written it,
542
+ * rather than left at what the content measured before it changed. */
543
+ _ownExtent(boundary, axis, span) {
544
+ const yoga = boundary.yoga;
545
+ if (axis === 'width') {
546
+ boundary._floorW = declaresOwnMinimum(boundary, axis)
547
+ ? yoga.getComputedWidth()
548
+ : span;
549
+ } else {
550
+ boundary._floorH = declaresOwnMinimum(boundary, axis)
551
+ ? yoga.getComputedHeight()
552
+ : span;
553
+ boundary._floorAtW = yoga.getComputedWidth();
554
+ }
555
+ this._floorsMeasured += 1;
556
+ }
557
+
558
+ /**
559
+ * One pass over a boundary alone, laid out as yoga lays an absolute box
560
+ * out where it sits. A size its style names is the size it is laid out
561
+ * at. Along an axis it names none, it is measured at what its content
562
+ * asks for and laid out again at exactly that: the second pass is where a
563
+ * percentage inside it resolves, against the size the first one found,
564
+ * and squeezes what stands beside it — which a box laid out once, at no
565
+ * size, never does. `height` is what the pass offers down a box with a
566
+ * height of its own: 0 in the collapse. One as tall as its content is
567
+ * offered nothing in the collapse over the window either, since yoga
568
+ * bounds an absolute box by nothing down, and offered 0 here it would be
569
+ * squashed to its padding, and what gives way in it with it. `pinned`
570
+ * says its width is held where the last pass left it, as the collapse
571
+ * holds every width (`freezeWidths`).
572
+ */
573
+ _layOutBoundary(boundary, height, dir, pinned = false) {
574
+ const yoga = boundary.yoga;
575
+ const style = boundary.style;
576
+ const across = !pinned && typeof style.width !== 'number';
577
+ const down = typeof style.height !== 'number';
578
+ this._scopedFloorPasses += 1;
579
+ yoga.calculateLayout(undefined, down ? undefined : height, dir);
580
+ if (!across && !down) return;
581
+ // on the copy (`onExactCopy`), and taken off again for the next pass
582
+ if (across) yoga.setWidth(yoga.getComputedWidth());
583
+ if (down) yoga.setHeight(yoga.getComputedHeight());
584
+ this._scopedFloorPasses += 1;
585
+ yoga.calculateLayout(undefined, undefined, dir);
586
+ if (across) setLayoutValue(yoga, 'width', style.width);
587
+ if (down) setLayoutValue(yoga, 'height', style.height);
588
+ }
589
+
515
590
  /** The floors on the children of every node found stale, from the extents
516
591
  * they carry — `writeFloors` for each. */
517
592
  _writeFloors(axis) {
@@ -35,3 +35,19 @@ export function createTransformer(options?: RefreshOptions): Promise<{
35
35
  matches(pathname: string): boolean;
36
36
  transform(source: string, filename: string): { code: string };
37
37
  }>;
38
+
39
+ /** One hot module compiled: `error` is null when it compiled, and what the
40
+ * compiler threw when it did not. */
41
+ export interface TransformEvent {
42
+ filename: string;
43
+ error: Error | null;
44
+ }
45
+
46
+ /**
47
+ * Called for every hot module the loader compiles. A save that does not
48
+ * compile is otherwise only printed — the app keeps running the module's last
49
+ * version — so this is how a host shows it. Returns an unsubscribe.
50
+ */
51
+ export function onTransform(
52
+ listener: (event: TransformEvent) => void,
53
+ ): () => void;
@@ -305,7 +305,8 @@ function buildTransformer({ babel, jsxTransform, refreshTransform }, options) {
305
305
  `const $RefreshSig$ = __ReactX11Refresh.createSignatureFunctionForTransform`,
306
306
  ...prelude,
307
307
  ];
308
- // Stacks through hot modules are off by these lines; dev-only.
308
+ // One statement a line, above the module's own first line: the source map
309
+ // below starts that many lines down to match.
309
310
  const preludeSource = preludeLines.join(';\n') + ';';
310
311
 
311
312
  let lastModuleInfo = null;
@@ -320,7 +321,7 @@ function buildTransformer({ babel, jsxTransform, refreshTransform }, options) {
320
321
  },
321
322
  transform(source, filename) {
322
323
  lastModuleInfo = null;
323
- const { code } = babel.transformSync(source, {
324
+ const { code, map } = babel.transformSync(source, {
324
325
  filename,
325
326
  babelrc: false,
326
327
  configFile: false,
@@ -331,7 +332,7 @@ function buildTransformer({ babel, jsxTransform, refreshTransform }, options) {
331
332
  guardPlugin,
332
333
  ],
333
334
  retainLines: true,
334
- sourceMaps: 'inline',
335
+ sourceMaps: true,
335
336
  });
336
337
  const { opaque, names } = lastModuleInfo;
337
338
  const exportsArg =
@@ -339,13 +340,55 @@ function buildTransformer({ babel, jsxTransform, refreshTransform }, options) {
339
340
  ? 'null'
340
341
  : `{ ${names.map((n) => `${JSON.stringify(n)}: ${n}`).join(', ')} }`;
341
342
  const footer = `\n;import.meta.hot && __ReactX11Refresh.moduleReady(import.meta.hot, import.meta.url, ${exportsArg});`;
342
- return { code: `${preludeSource}\n${code}${footer}` };
343
+ return {
344
+ code:
345
+ `${preludeSource}\n${code}${footer}\n` +
346
+ inlineSourceMap(map, preludeLines.length),
347
+ };
343
348
  },
344
349
  };
345
350
  }
346
351
 
352
+ const transformListeners = new Set();
353
+
354
+ /**
355
+ * Called for every hot module this loader compiles, with `{ filename, error }`
356
+ * — `error` null when it compiled. The seam a host needs for a save that does
357
+ * not compile: hot-module-replacement catches the error of a reload and only
358
+ * prints it, and the app keeps running the module's last version, so nothing
359
+ * else tells the host. `react-x11 dev` shows it in a window. Returns an
360
+ * unsubscribe.
361
+ */
362
+ export function onTransform(listener) {
363
+ transformListeners.add(listener);
364
+ return () => transformListeners.delete(listener);
365
+ }
366
+
367
+ function reportTransform(event) {
368
+ for (const listener of [...transformListeners]) {
369
+ try {
370
+ listener(event);
371
+ } catch (err) {
372
+ console.error('react-x11/refresh: an onTransform listener threw', err);
373
+ }
374
+ }
375
+ }
376
+
347
377
  let registered = false;
348
378
 
379
+ /**
380
+ * Babel's map for a module, as the comment that carries it, moved `lines`
381
+ * down: babel describes its own output, and the prelude goes in above that.
382
+ * An empty line in a map's mappings is a `;`, so the shift is that many of
383
+ * them. hot-module-replacement, the layer above, composes this map into its
384
+ * own — which is what a stack trace through a hot module is read through.
385
+ */
386
+ export function inlineSourceMap(map, lines) {
387
+ const shifted = { ...map, mappings: ';'.repeat(lines) + map.mappings };
388
+ const payload = Buffer.from(JSON.stringify(shifted)).toString('base64');
389
+ return `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${payload}\n`;
390
+ }
391
+
349
392
  /**
350
393
  * Which files stay out of the hot graph: every package in node_modules,
351
394
  * react-x11's own src/, and whatever the app's `ignore` names.
@@ -426,10 +469,17 @@ export async function registerRefresh(options = {}) {
426
469
  if (hotIgnore(filename)) {
427
470
  return nextLoad(url, context);
428
471
  }
429
- const { code } = transformer.transform(
430
- readFileSync(filename, 'utf8'),
431
- filename,
432
- );
472
+ let code;
473
+ try {
474
+ ({ code } = transformer.transform(
475
+ readFileSync(filename, 'utf8'),
476
+ filename,
477
+ ));
478
+ } catch (error) {
479
+ reportTransform({ filename, error });
480
+ throw error;
481
+ }
482
+ reportTransform({ filename, error: null });
433
483
  return { format: 'module', source: code, shortCircuit: true };
434
484
  },
435
485
  });