react-x11 2.22.12 → 2.23.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.22.12",
3
+ "version": "2.23.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/cocoa/main.js CHANGED
@@ -17,6 +17,11 @@
17
17
  // import's). Anywhere else it does nothing, so one command line serves
18
18
  // every platform: on Linux the entry runs as it would without it, and so
19
19
  // does a Worker the app starts itself (it inherits this `--import`).
20
+ //
21
+ // Of the checks it skips, it keeps one: a `<Frame>` pane stays where it is.
22
+ // The host forks the pane with the node flags it started with, this
23
+ // `--import` among them, and a pane on a worker has no IPC channel to its
24
+ // host (src/cocoa/relaunch.js, `relaunchVeto`).
20
25
  import { isMainThread, workerData } from 'node:worker_threads';
21
26
 
22
27
  import { loadNative } from './native.js';
@@ -24,7 +29,11 @@ import { bootstrapWorker, relaunch } from './relaunch.js';
24
29
 
25
30
  if (!isMainThread && workerData?.reactX11State) {
26
31
  bootstrapWorker(workerData.reactX11State);
27
- } else if (isMainThread && process.platform === 'darwin') {
32
+ } else if (
33
+ isMainThread &&
34
+ process.platform === 'darwin' &&
35
+ process.env.REACT_X11_FRAME !== '1'
36
+ ) {
28
37
  const entry = process.argv[1];
29
38
  if (!entry) {
30
39
  throw new Error(
@@ -67,6 +67,13 @@ export function relaunchVeto({
67
67
  if (env.REACT_X11_BACKEND && env.REACT_X11_BACKEND !== 'cocoa') {
68
68
  return `REACT_X11_BACKEND=${env.REACT_X11_BACKEND}`;
69
69
  }
70
+ // A `<Frame>` pane (REACT_X11_FRAME is what the host sets on the fork)
71
+ // has no AppKit for the main thread to keep — its pixels reach the host
72
+ // through shared IOSurfaces (src/cocoa/panewindow.js) — and its entry
73
+ // talks to the host over the IPC channel fork() sets up, which is the
74
+ // main thread's: on a worker there is no `process.send`, and the pane
75
+ // exited as it started (src/frame/child.js).
76
+ if (env.REACT_X11_FRAME === '1') return 'a <Frame> pane';
70
77
  // a REPL, `node -e`, or an entry that is not a file a worker can load
71
78
  if (!entry) return 'no entry script';
72
79
  // a single executable carries its entry inside the binary, where a worker
package/src/events.js CHANGED
@@ -1213,7 +1213,7 @@ export class EventManager {
1213
1213
  n.props.onMouseEnter?.(this._makeEvent('mouseEnter', native, n));
1214
1214
  }
1215
1215
  this.hoverPath = newPath;
1216
- this._updateCursor(newPath);
1216
+ this._updateCursor(newPath, native);
1217
1217
  }
1218
1218
 
1219
1219
  /**
@@ -1322,14 +1322,32 @@ export class EventManager {
1322
1322
  callHandler(node, 'unstable_onAttention', handler, ev);
1323
1323
  }
1324
1324
 
1325
- /** Apply the deepest hovered node's `cursor` prop to the window.
1326
- * Feature-detected: needs ntk with Window.setCursor (> 3.1.0). */
1327
- _updateCursor(path) {
1325
+ /** Apply the deepest hovered node's cursor to the window: its `cursor`
1326
+ * style, or what it says of the point under the pointer (`cursorAt`), or
1327
+ * its `defaultCursor` — and failing all three, its parent's. Asked on
1328
+ * every motion. Feature-detected: needs ntk with Window.setCursor
1329
+ * (> 3.1.0). */
1330
+ _updateCursor(path, native = null) {
1328
1331
  const wnd = this.node.window;
1329
1332
  if (typeof wnd.setCursor !== 'function') return;
1333
+ // An element that draws what is inside it — a document, a graph, an
1334
+ // editor — is one node with many cursors, and only it knows which part
1335
+ // is a link, a handle or text: it answers for the point, in the device
1336
+ // pixels `abs` is in, as `textIndexAt` does. While a capture holds the
1337
+ // pointer it is not asked — hover is frozen for the gesture and the
1338
+ // point may be anywhere — and keeps what it named where the gesture
1339
+ // began, while a style the press changed still shows.
1340
+ const asking = native != null && !this._captured();
1341
+ const named = (this._pointCursors ??= new WeakMap());
1330
1342
  let cursor = null;
1331
1343
  for (let i = path.length - 1; i >= 0; i--) {
1332
- const c = path[i].style.cursor ?? path[i].defaultCursor;
1344
+ const node = path[i];
1345
+ let c = node.style.cursor;
1346
+ if (c == null && node.cursorAt) {
1347
+ if (asking) named.set(node, node.cursorAt(native.x, native.y) ?? null);
1348
+ c = named.get(node) ?? null;
1349
+ }
1350
+ c ??= node.defaultCursor;
1333
1351
  if (c != null) {
1334
1352
  cursor = c;
1335
1353
  break;
@@ -1687,7 +1705,11 @@ export class EventManager {
1687
1705
  // …and nothing scrolls to meet it either: the node is coming back to
1688
1706
  // the arrangement it left, and this reveal's layout has not run, so a
1689
1707
  // scroll here would be computed from a rect that does not exist yet.
1690
- if (!restoring) this._scrollIntoView(node);
1708
+ // Nor for a press: the node is under the pointer, so the part that
1709
+ // matters is on screen already, and a document taller than its pane
1710
+ // was scrolled to its top by every click on it — the drag that
1711
+ // followed selecting from there.
1712
+ if (!restoring && reason !== 'pointer') this._scrollIntoView(node);
1691
1713
  if (this.keyboardFocused) {
1692
1714
  this._defaultFocusOn = true;
1693
1715
  node.defaultFocus?.({ reason, backwards });
@@ -4,12 +4,20 @@
4
4
  // `process`. Kept import-light and JSX-free on purpose: it runs before
5
5
  // anything decided what loaders this process has.
6
6
 
7
+ import { isMainThread } from 'node:worker_threads';
8
+
7
9
  import { runFrameChild } from './childmain.js';
8
10
 
9
11
  if (typeof process.send !== 'function') {
10
12
  console.error(
11
- 'react-x11: frame/child.js is the entry a <Frame> forks — it needs the ' +
12
- 'IPC channel fork() sets up, and cannot be run directly.',
13
+ isMainThread
14
+ ? 'react-x11: frame/child.js is the entry a <Frame> forks — it needs ' +
15
+ 'the IPC channel fork() sets up, and cannot be run directly.'
16
+ : // the channel is the main thread's, and something moved the pane
17
+ // off it (src/cocoa/relaunch.js keeps a pane where it is)
18
+ 'react-x11: frame/child.js is running on a worker thread, which ' +
19
+ 'has no IPC channel — a <Frame> pane has to stay on the main ' +
20
+ 'thread of its process.',
13
21
  );
14
22
  process.exit(1);
15
23
  }
@@ -45,6 +45,7 @@ import React, {
45
45
 
46
46
  import { useAppOrNull } from '../appcontext.js';
47
47
  import { canEmbed } from '../embedding.js';
48
+ import { WHEEL_NOTCH_PX } from '../events.js';
48
49
  import { FrameEnv } from './env.js';
49
50
  import { CallbackTable, PROTOCOL } from './protocol.js';
50
51
 
@@ -607,9 +608,13 @@ function PaneHostView({
607
608
  onMouseDown: forward('mousedown', (ev) => ({ keycode: ev.button ?? 1 })),
608
609
  onMouseUp: forward('mouseup', (ev) => ({ keycode: ev.button ?? 1 })),
609
610
  onMouseMove: forward('mousemove', () => ({})),
611
+ // The host's event is in pixels — events.js turned each notch into
612
+ // WHEEL_NOTCH_PX of them — and the pane's window turns what it is sent
613
+ // into pixels again, so it is sent notches: sent the pixels, one notch
614
+ // scrolled a page 2304 of them.
610
615
  onWheel: forward('wheel', (ev) => ({
611
- deltaX: ev.deltaX ?? 0,
612
- deltaY: ev.deltaY ?? 0,
616
+ deltaX: (ev.deltaX ?? 0) / WHEEL_NOTCH_PX,
617
+ deltaY: (ev.deltaY ?? 0) / WHEEL_NOTCH_PX,
613
618
  deltaMode: ev.deltaMode ?? 'line',
614
619
  smooth: Boolean(ev.smooth),
615
620
  source: 'forwarded',
package/src/node.d.ts CHANGED
@@ -699,6 +699,15 @@ export declare class Node {
699
699
  /** The cursor to show over this element when nothing in the style says
700
700
  * otherwise — `'text'` for something editable. A `cursor` style wins. */
701
701
  defaultCursor?: string;
702
+ /**
703
+ * The cursor for a point inside this element, for one that draws parts
704
+ * only it can tell apart — a link in a document, a handle in a graph, the
705
+ * text in an editor. Asked on every motion over it, with the pointer in
706
+ * device pixels, the unit `abs` is in. Null says nothing in particular is
707
+ * there, and `defaultCursor` answers. A `cursor` style still wins, and a
708
+ * capture holding the pointer freezes the cursor with the rest of hover.
709
+ */
710
+ cursorAt?(x: number, y: number): string | null;
702
711
  /**
703
712
  * The ARIA role this element is when the application writes none — the
704
713
  * registered-element counterpart of `<textinput>` defaulting to
@@ -138,8 +138,14 @@ export class NodeAnimation {
138
138
  }
139
139
  // A grid reads its tracks off the style, where yoga never sees them, so
140
140
  // a change to one asks the algorithm again — and gives one that threw
141
- // another go.
142
- if (this.yoga && gridContainerMoved(displayed, this.style)) {
141
+ // another go. Asked of a layout host alone, the only node it acts on:
142
+ // seven properties read off two styles of every shape is a scan every
143
+ // node paid on each of the several swaps a mount makes, 3% of it.
144
+ if (
145
+ this.yoga &&
146
+ (this._host !== null || this._layoutAbandoned !== null) &&
147
+ gridContainerMoved(displayed, this.style)
148
+ ) {
143
149
  if (this._host !== null) this._hostChanged();
144
150
  else if (this._layoutAbandoned !== null) {
145
151
  this._layoutAbandoned = null;
@@ -8,6 +8,7 @@ import {
8
8
  createLayoutNode,
9
9
  isMeasuringExactly,
10
10
  measuringExactly,
11
+ setLayoutValue,
11
12
  } from '../styles.js';
12
13
  import { Yoga } from '../yoga.js';
13
14
  import {
@@ -342,11 +343,11 @@ export class NodeLayoutHost {
342
343
  // A floor written while it was a flex item means nothing to a layout,
343
344
  // which sizes the child itself — and would outlast it as a minimum.
344
345
  if (child._floorMinW != null) {
345
- cy.setMinWidth(child.style.minWidth);
346
+ setLayoutValue(cy, 'minWidth', child.style.minWidth);
346
347
  child._floorMinW = undefined;
347
348
  }
348
349
  if (child._floorMinH != null) {
349
- cy.setMinHeight(child.style.minHeight);
350
+ setLayoutValue(cy, 'minHeight', child.style.minHeight);
350
351
  child._floorMinH = undefined;
351
352
  }
352
353
  child._hostSlot = null;
@@ -7,6 +7,7 @@ import {
7
7
  localTextStyleChanged,
8
8
  containerAnswers,
9
9
  } from '../styles.js';
10
+ import { reportStyleProblem } from '../errors.js';
10
11
  import { DEV } from './util.js';
11
12
 
12
13
  /** How many extra layout passes a flush spends settling `@container` blocks
@@ -193,7 +194,25 @@ export class NodeQueries {
193
194
  // catch-up looks the dirty flags are spent and no leaf's height moved
194
195
  // — so the floor of a card that turned from a row into a column would
195
196
  // stay the row's, and yoga would squeeze the column down to it.
196
- if (applyLayoutStyle(this.yoga, this.style, before) && this.root) {
197
+ //
198
+ // This runs in a frame, so a value the layout refuses is dropped and
199
+ // reported rather than thrown: nothing would catch it.
200
+ const refused = [];
201
+ const moved = applyLayoutStyle(this.yoga, this.style, before, refused);
202
+ for (const { key, value, error } of refused) {
203
+ // an enum's own message already says what it is and what it takes
204
+ const said = error.message.startsWith('react-x11:')
205
+ ? error.message
206
+ : `react-x11: invalid ${key} ${JSON.stringify(value)} ` +
207
+ `(${error.message})`;
208
+ reportStyleProblem(
209
+ this,
210
+ `${said} in <${this.kind} style>, applied when a query block ` +
211
+ 'started matching',
212
+ 'The declaration is dropped, as CSS drops one it cannot parse',
213
+ );
214
+ }
215
+ if (moved && this.root) {
197
216
  this.root._floorsDirty = true;
198
217
  this.root._floorsContentDirty = true;
199
218
  this.root._floorsSources.add(this);
@@ -889,14 +889,8 @@ export const Scrollable = (Base) =>
889
889
  ? this.yoga.getComputedWidth() - left - w
890
890
  : left;
891
891
  const far = near + w;
892
- if (bottom > this.scrollY + this.abs.height) {
893
- this.scrollY = bottom - this.abs.height;
894
- }
895
- if (top < this.scrollY) this.scrollY = top;
896
- if (far > this.scrollX + this.abs.width) {
897
- this.scrollX = far - this.abs.width;
898
- }
899
- if (near < this.scrollX) this.scrollX = near;
892
+ this.scrollY = nearestScroll(this.scrollY, this.abs.height, top, bottom);
893
+ this.scrollX = nearestScroll(this.scrollX, this.abs.width, near, far);
900
894
  }
901
895
 
902
896
  paint(ctx) {
@@ -1035,6 +1029,23 @@ export const Scrollable = (Base) =>
1035
1029
  }
1036
1030
  };
1037
1031
 
1032
+ /**
1033
+ * Where one axis of a viewport scrolls to bring `[start, end)` into view,
1034
+ * the way CSSOM View's `block: nearest` does it: a span already inside, or
1035
+ * one that covers the viewport, leaves it where it is — nothing more of it
1036
+ * can be shown — and one partly or wholly out comes in by the edge that
1037
+ * moves it least, which is its near edge where it fits and its far edge
1038
+ * where it is the larger. A document taller than its pane was scrolled to
1039
+ * its top, wherever it had been read to.
1040
+ */
1041
+ function nearestScroll(scroll, size, start, end) {
1042
+ const before = start < scroll;
1043
+ const after = end > scroll + size;
1044
+ if (before === after) return scroll;
1045
+ const larger = end - start > size;
1046
+ return before !== larger ? start : end - size;
1047
+ }
1048
+
1038
1049
  // An arrow key scrolls by a wheel notch — literally the one events.js
1039
1050
  // converts a notch into, so the two input routes agree about what one step
1040
1051
  // is however far a notch turns out to be.
package/src/nodes/text.js CHANGED
@@ -1,7 +1,11 @@
1
1
  // <text> and its chunks: shaped, wrapped and elided text, the line bands a
2
2
  // selection paints, and the glyph strip.
3
3
 
4
- import { localTextStyleChanged, TEXT_REMEASURE } from '../styles.js';
4
+ import {
5
+ lineHeightOf,
6
+ localTextStyleChanged,
7
+ TEXT_REMEASURE,
8
+ } from '../styles.js';
5
9
  import { cssColorStraight } from 'ntk/color';
6
10
  import { hooks as a11yHooks } from '../a11y.js';
7
11
  import { codePointAtOffset, codeUnitOffsets } from '../textrange.js';
@@ -419,7 +423,7 @@ export class TextNode extends Node {
419
423
  layout = fonts.layout(spans, base, {
420
424
  maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,
421
425
  align: this.style.textAlign,
422
- lineHeight: this.style.lineHeight,
426
+ lineHeight: lineHeightOf(this.style.lineHeight),
423
427
  maxLines: Number.isFinite(maxLines) ? maxLines : undefined,
424
428
  // 'clip' is ntk's default, so an unset property and the CSS default
425
429
  // are the same request rather than two paths through the layout.
@@ -3,6 +3,7 @@
3
3
  // minimums. Free functions over nodes; WindowNode drives them from size.js,
4
4
  // and a layout host from layouthost.js.
5
5
 
6
+ import { setLayoutValue } from '../../styles.js';
6
7
  import { Yoga } from '../../yoga.js';
7
8
  import { NO_CHILDREN } from '../util.js';
8
9
 
@@ -688,7 +689,9 @@ export function freezeWidths(node, out) {
688
689
 
689
690
  /** …and back to the width the style asks for, on exactly what was pinned. */
690
691
  export function restoreWidths(frozen) {
691
- for (const child of frozen) child.yoga.setWidth(child.style.width);
692
+ for (const child of frozen) {
693
+ setLayoutValue(child.yoga, 'width', child.style.width);
694
+ }
692
695
  }
693
696
 
694
697
  /**
@@ -909,14 +912,9 @@ export function writeFloors(node, axis) {
909
912
  if (extent > 0) floor = extent;
910
913
  }
911
914
  if ((horizontal ? child._floorMinW : child._floorMinH) === floor) continue;
912
- const value = floor ?? child.style[own];
913
- if (horizontal) {
914
- child.yoga.setMinWidth(value);
915
- child._floorMinW = floor;
916
- } else {
917
- child.yoga.setMinHeight(value);
918
- child._floorMinH = floor;
919
- }
915
+ setLayoutValue(child.yoga, own, floor ?? child.style[own]);
916
+ if (horizontal) child._floorMinW = floor;
917
+ else child._floorMinH = floor;
920
918
  }
921
919
  }
922
920
 
package/src/palette.js CHANGED
@@ -503,6 +503,25 @@ const desktopPalettes = new Map();
503
503
  * `'no-preference'` means *use your own default*, which is the light one.
504
504
  */
505
505
  export function paletteFor(appearance) {
506
+ // An appearance is a frozen snapshot, replaced whole when the desktop
507
+ // changes (appearance.js), so the last one asked about answers by
508
+ // identity. `baseTheme()` asks with the same snapshot for every node
509
+ // created — each one detached, with no theme above it yet — and spelling
510
+ // the key to the palettes below was 200 ns of each on a desktop with an
511
+ // accent: a thousand rows scrolling into a table, a document mounting.
512
+ if (appearance === lastAppearance) return lastPalette;
513
+ const palette = paletteOf(appearance);
514
+ if (Object.isFrozen(appearance)) {
515
+ lastAppearance = appearance;
516
+ lastPalette = palette;
517
+ }
518
+ return palette;
519
+ }
520
+
521
+ let lastAppearance = null;
522
+ let lastPalette = null;
523
+
524
+ function paletteOf(appearance) {
506
525
  const scheme = appearance.colorScheme === 'dark' ? DarkTheme : DefaultTheme;
507
526
  if (appearance.palette) return withDesktopPalette(scheme, appearance.palette);
508
527
  const { accent, accentText, selection } = appearance;
package/src/styles.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  GRID_CONTAINER_PROPS,
12
12
  GRID_ITEM_PROPS,
13
13
  } from './grid.js';
14
- import { Yoga } from './yoga.js';
14
+ import { Yoga, layoutLoaded } from './yoga.js';
15
15
 
16
16
  const FLEX_DIRECTION = {
17
17
  row: Yoga.FLEX_DIRECTION_ROW,
@@ -855,6 +855,19 @@ const GRID_VALUES = new Set([...GRID_CONTAINER_PROPS, ...GRID_ITEM_PROPS]);
855
855
  * grammar is concerned), so this checks the shape and never the colours.
856
856
  */
857
857
  function validateValue(key, value, where) {
858
+ const text = TEXT_VALUE_CHECKS.get(key);
859
+ if (text !== undefined) {
860
+ // unset, the way a conditional writes it
861
+ if (value == null || value === '' || isToken(value)) return;
862
+ const [valid, expected] = text;
863
+ if (!valid(value)) {
864
+ throw new Error(
865
+ `react-x11: invalid ${key} ${JSON.stringify(value)} in ${where} ` +
866
+ `(expected ${expected})`,
867
+ );
868
+ }
869
+ return;
870
+ }
858
871
  if (key === 'letterSpacing') {
859
872
  if (
860
873
  value != null &&
@@ -913,6 +926,26 @@ function validateValue(key, value, where) {
913
926
  }
914
927
  return;
915
928
  }
929
+ // A layout value the layout cannot take — `width: 'hidden'`, `padding:
930
+ // 'auto'`, `flexDirection: 'sideways'` — throws where it is applied, and
931
+ // inside a query block that is a frame, not a commit: a block that starts
932
+ // matching on a resize took the process down. So it is asked here, of the
933
+ // same appliers, wherever it is written. A `$token` is not a length until
934
+ // it resolves, and is answered by the frame then (`_sizeQueriesChanged`).
935
+ const refused =
936
+ isToken(value) || mentionsToken(value) ? null : layoutRefusal(key, value);
937
+ if (refused) {
938
+ // an enum's own message lists the choices; yoga's names only itself
939
+ if (refused.message.startsWith('react-x11:')) {
940
+ refused.message += `\n in ${where}`;
941
+ throw refused;
942
+ }
943
+ const auto = layoutRefusal(key, 'auto') === null ? ", or 'auto'" : '';
944
+ throw new Error(
945
+ `react-x11: invalid ${key} ${JSON.stringify(value)} in ${where} ` +
946
+ `(expected a number of pixels or a percentage like '50%'${auto})`,
947
+ );
948
+ }
916
949
  const grid = GRID_VALUES.has(key);
917
950
  if (!grid && key !== 'backgroundImage' && key !== 'boxShadow') return;
918
951
  try {
@@ -1566,11 +1599,15 @@ const TOKEN_IN_VALUE = /\$[A-Za-z_][A-Za-z0-9_-]*/g;
1566
1599
  const mentionsToken = (v) =>
1567
1600
  typeof v === 'string' && v.charCodeAt(0) !== 36 && v.includes('$');
1568
1601
 
1569
- /** The nested blocks a token can hide in, one level down: a state block,
1570
- * whose values are style values, and `animation`, whose values are the
1571
- * per-property declarations *whose* values are style values. Both are
1572
- * walked by everything that looks for a `$name`, so a themed loop resolves
1573
- * by the same rules as a themed hover. */
1602
+ /** The nested blocks a token can hide in, one level down: a state block or
1603
+ * a query block, whose values are style values, and `animation`, whose
1604
+ * values are the per-property declarations *whose* values are style
1605
+ * values. All are walked by everything that looks for a `$name`, so a
1606
+ * themed loop resolves by the same rules as a themed hover — and a themed
1607
+ * wide layout too. The query blocks were missed: `'@width >= 600':
1608
+ * { padding: '$gutter' }` handed yoga the string `'$gutter'` the moment
1609
+ * it matched, which threw from the frame. */
1610
+ const holdsStyle = (key) => isState(key) || isQuery(key);
1574
1611
  const animationBlocks = (style) =>
1575
1612
  style.animation && typeof style.animation === 'object'
1576
1613
  ? Object.values(style.animation).filter((e) => e && typeof e === 'object')
@@ -1580,7 +1617,7 @@ export function styleUsesTokens(style) {
1580
1617
  for (const key of Object.keys(style)) {
1581
1618
  const v = style[key];
1582
1619
  if (isToken(v) || mentionsToken(v)) return true;
1583
- if (key.charCodeAt(0) === 58 && v && styleUsesTokens(v)) return true;
1620
+ if (holdsStyle(key) && v && styleUsesTokens(v)) return true;
1584
1621
  }
1585
1622
  return animationBlocks(style).some(styleUsesTokens);
1586
1623
  }
@@ -1594,7 +1631,7 @@ export function tokenNames(style, out = new Set()) {
1594
1631
  if (isToken(v)) out.add(v);
1595
1632
  else if (mentionsToken(v))
1596
1633
  for (const m of v.match(TOKEN_IN_VALUE) ?? []) out.add(m);
1597
- else if (key.charCodeAt(0) === 58 && v) tokenNames(v, out);
1634
+ else if (holdsStyle(key) && v) tokenNames(v, out);
1598
1635
  }
1599
1636
  for (const block of animationBlocks(style)) tokenNames(block, out);
1600
1637
  return out;
@@ -1624,7 +1661,7 @@ export function stripTokens(style) {
1624
1661
  if (key === 'animation' && animationBlocks(style).some(styleUsesTokens)) {
1625
1662
  continue;
1626
1663
  }
1627
- out[key] = key.charCodeAt(0) === 58 && v ? stripTokens(v) : v;
1664
+ out[key] = holdsStyle(key) && v ? stripTokens(v) : v;
1628
1665
  }
1629
1666
  return out;
1630
1667
  }
@@ -1691,7 +1728,7 @@ export function resolveTokens(
1691
1728
  else if (strict) {
1692
1729
  found.push(unknownToken(unknown, theme, `${where} ${key}`));
1693
1730
  }
1694
- } else if (key.charCodeAt(0) === 58 && v) {
1731
+ } else if (holdsStyle(key) && v) {
1695
1732
  out[key] = resolveTokens(v, theme, `${where} ${key}`, strict, found);
1696
1733
  } else if (key === 'animation' && v && typeof v === 'object') {
1697
1734
  const loops = {};
@@ -2011,9 +2048,22 @@ const NO_STYLE = Object.freeze({});
2011
2048
  * name of both styles was most of the cost of a node that mounts — a
2012
2049
  * virtualized list replaces its rows at input rate.
2013
2050
  *
2051
+ * `problems` is for a caller React is not under — a query block that
2052
+ * starts matching mid-frame — where a throw would leave the frame and take
2053
+ * the process with it. A value the layout cannot take is then dropped, the
2054
+ * property going back to its default as CSS drops a declaration it cannot
2055
+ * parse, and `{ key, value, error }` is pushed for the caller to report.
2056
+ * Without it the value throws, which is what a commit wants: React routes
2057
+ * it to a boundary.
2058
+ *
2014
2059
  * @returns true if any layout-affecting prop changed
2015
2060
  */
2016
- export function applyLayoutStyle(yogaNode, props, oldProps = NO_STYLE) {
2061
+ export function applyLayoutStyle(
2062
+ yogaNode,
2063
+ props,
2064
+ oldProps = NO_STYLE,
2065
+ problems = null,
2066
+ ) {
2017
2067
  let changed = false;
2018
2068
  // A placed node's insets — sticky's thresholds, or whatever a registered
2019
2069
  // position reads them as — are the placement's, never offsets: yoga gets
@@ -2030,7 +2080,12 @@ export function applyLayoutStyle(yogaNode, props, oldProps = NO_STYLE) {
2030
2080
  ? !samePosition(props.position, oldProps.position)
2031
2081
  : props[key] !== oldProps[key];
2032
2082
  if (!moved && !(flipped && inset)) continue;
2033
- applier(yogaNode, placed && inset ? undefined : props[key]);
2083
+ const value = placed && inset ? undefined : props[key];
2084
+ // `null` is unset, the way a conditional writes it — `wide ? 240 :
2085
+ // null` — and as it is for every other property in this vocabulary;
2086
+ // yoga's own setters throw on it
2087
+ if (problems === null) applier(yogaNode, value ?? undefined);
2088
+ else applyOrDrop(applier, yogaNode, key, value ?? undefined, problems);
2034
2089
  changed = true;
2035
2090
  }
2036
2091
  // …and the ones the new style dropped, which go back to yoga's default
@@ -2051,6 +2106,72 @@ export function applyLayoutStyle(yogaNode, props, oldProps = NO_STYLE) {
2051
2106
  return changed;
2052
2107
  }
2053
2108
 
2109
+ /**
2110
+ * One layout property set on its own — a width put back after the content
2111
+ * floors pinned it, a minimum restored under a floor — by the rules
2112
+ * `applyLayoutStyle` sets it by: `null` is unset, and a value the layout
2113
+ * refuses is dropped. A refused one was reported where it was first
2114
+ * applied, and throwing here would throw from inside a layout pass.
2115
+ */
2116
+ export function setLayoutValue(yogaNode, key, value) {
2117
+ const applier = LAYOUT_APPLIER_OF.get(key);
2118
+ try {
2119
+ applier(yogaNode, value ?? undefined);
2120
+ } catch {
2121
+ applier(yogaNode, undefined);
2122
+ }
2123
+ }
2124
+
2125
+ /** One declaration, dropped rather than thrown when the layout refuses it
2126
+ * (`applyLayoutStyle`'s `problems`). */
2127
+ function applyOrDrop(applier, yogaNode, key, value, problems) {
2128
+ try {
2129
+ applier(yogaNode, value);
2130
+ } catch (error) {
2131
+ applier(yogaNode, undefined);
2132
+ problems.push({ key, value, error });
2133
+ }
2134
+ }
2135
+
2136
+ // A yoga node no layout runs on, for development to ask whether a value is
2137
+ // one the layout can take: the question the frame asks, so the two cannot
2138
+ // disagree about what a length is.
2139
+ let scratchNode = null;
2140
+
2141
+ // What the layout has already taken, per property, so development asks
2142
+ // yoga once per spelling rather than once per style it checks: a list of a
2143
+ // thousand rows checks the same six values a thousand times, and a setter
2144
+ // is a call into WebAssembly. A property that takes one number takes them
2145
+ // all — a length, a factor — and one that refuses them is an enum, so
2146
+ // numbers are remembered as one entry.
2147
+ const takenValues = new Map(
2148
+ [...LAYOUT_APPLIER_OF.keys()].map((key) => [key, new Set()]),
2149
+ );
2150
+ const NUMBERS = Symbol('numbers');
2151
+ const TAKEN_MAX = 256;
2152
+
2153
+ /** What applying `value` to `key` throws, or null when the layout takes
2154
+ * it — and for a key that is not a layout property. */
2155
+ function layoutRefusal(key, value) {
2156
+ const taken = takenValues.get(key);
2157
+ if (taken === undefined || value == null) return null;
2158
+ if (taken.has(typeof value === 'number' ? NUMBERS : value)) return null;
2159
+ if (scratchNode === null) {
2160
+ // `createStyles` validates at module scope, before `createRoot` has
2161
+ // loaded the engine — and the commit validates the same style again
2162
+ if (!layoutLoaded()) return null;
2163
+ scratchNode = Yoga.Node.create();
2164
+ }
2165
+ try {
2166
+ LAYOUT_APPLIER_OF.get(key)(scratchNode, value);
2167
+ } catch (error) {
2168
+ return error;
2169
+ }
2170
+ if (typeof value === 'number') taken.add(NUMBERS);
2171
+ else if (taken.size < TAKEN_MAX) taken.add(value);
2172
+ return null;
2173
+ }
2174
+
2054
2175
  /** Two `position` values asking for the same thing. A registered one written
2055
2176
  * with its options is an object, and an inline object is a new one every
2056
2177
  * render — which must not cost a layout pass per commit. */
@@ -2098,16 +2219,81 @@ export function paintPropsChanged(props, oldProps = {}) {
2098
2219
  );
2099
2220
  }
2100
2221
 
2222
+ // The text values a style can get wrong in a way the engines do not survive:
2223
+ // a `fontStyle` of `7` threw from inside ntk's face matching, a `lineHeight`
2224
+ // of `'24px'` made a paragraph NaN pixels tall — thrown from the layout
2225
+ // pass, where nothing catches it — and a `fontSize` of `'20px'` set no text
2226
+ // at all. A number is a number or a numeric string, the way the coercion
2227
+ // these always went through read one; anything else is not the property,
2228
+ // and text inherits past it, as CSS inherits past a declaration it cannot
2229
+ // parse. Development says so where the style is written (`validateValue`).
2230
+ const NUMERIC_TEXT = /^\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?\s*$/i;
2231
+ const numericOf = (v) =>
2232
+ typeof v === 'number'
2233
+ ? v
2234
+ : typeof v === 'string' && NUMERIC_TEXT.test(v)
2235
+ ? Number(v)
2236
+ : NaN;
2237
+ const FONT_WEIGHT_WORDS = new Set(['normal', 'bold', 'bolder', 'lighter']);
2238
+ const FONT_STYLE = /^(?:normal|italic|oblique)\b/;
2239
+
2240
+ const isFontSize = (v) => {
2241
+ const n = numericOf(v);
2242
+ return n >= 0 && n < Infinity;
2243
+ };
2244
+ const isFontWeight = (v) => {
2245
+ if (FONT_WEIGHT_WORDS.has(v)) return true;
2246
+ const n = numericOf(v);
2247
+ return n >= 1 && n <= 1000;
2248
+ };
2249
+ const isFontStyle = (v) => typeof v === 'string' && FONT_STYLE.test(v);
2250
+ const isFontFamily = (v) => typeof v === 'string' && v.trim() !== '';
2251
+
2252
+ /** A `lineHeight` a layout can multiply by, or undefined for the font's own. */
2253
+ export function lineHeightOf(value) {
2254
+ const n = numericOf(value);
2255
+ return n >= 0 && n < Infinity ? n : undefined;
2256
+ }
2257
+
2258
+ /** What each of them is, for development's message. */
2259
+ const TEXT_VALUE_CHECKS = new Map([
2260
+ ['fontSize', [isFontSize, 'a number of pixels, like fontSize: 14']],
2261
+ [
2262
+ 'fontWeight',
2263
+ [
2264
+ isFontWeight,
2265
+ "a weight from 1 to 1000, like 600, or 'normal', 'bold', 'bolder' or " +
2266
+ "'lighter'",
2267
+ ],
2268
+ ],
2269
+ ['fontStyle', [isFontStyle, "'normal', 'italic' or 'oblique'"]],
2270
+ [
2271
+ 'fontFamily',
2272
+ [isFontFamily, "a family's name, like 'Inter' or 'monospace'"],
2273
+ ],
2274
+ [
2275
+ 'lineHeight',
2276
+ [
2277
+ (v) => lineHeightOf(v) !== undefined,
2278
+ "a multiple of the font's own line height, like lineHeight: 1.5",
2279
+ ],
2280
+ ],
2281
+ ]);
2282
+
2101
2283
  /** Resolved text style (TextLayout base style) from props + inherited. */
2102
2284
  export function textStyleFrom(props, inherited) {
2103
2285
  const variantNumeric = props.fontVariantNumeric ?? inherited.variantNumeric;
2104
2286
  const featureSettings =
2105
2287
  props.fontFeatureSettings ?? inherited.featureSettings;
2106
2288
  return {
2107
- family: props.fontFamily ?? inherited.family,
2108
- size: props.fontSize ?? inherited.size,
2109
- weight: props.fontWeight ?? inherited.weight,
2110
- style: props.fontStyle ?? inherited.style,
2289
+ family: isFontFamily(props.fontFamily)
2290
+ ? props.fontFamily
2291
+ : inherited.family,
2292
+ size: isFontSize(props.fontSize) ? props.fontSize : inherited.size,
2293
+ weight: isFontWeight(props.fontWeight)
2294
+ ? props.fontWeight
2295
+ : inherited.weight,
2296
+ style: isFontStyle(props.fontStyle) ? props.fontStyle : inherited.style,
2111
2297
  // ntk's name for it is `variations`; the prop is spelled after the CSS
2112
2298
  // property, like every other name in this vocabulary
2113
2299
  variations: props.fontVariationSettings ?? inherited.variations,
@@ -148,7 +148,8 @@ export interface ScrollableNode extends DrawnNode {
148
148
  canScroll(dx: number, dy: number): boolean;
149
149
  /**
150
150
  * Scroll the minimum amount on both axes that makes a descendant fully
151
- * visible. Safe to call right after that node mounts — the request is
151
+ * visible; one larger than the viewport is left where it covers it and
152
+ * otherwise brought in by its nearer edge (CSSOM View's `nearest`). Safe to call right after that node mounts — the request is
152
153
  * resolved on the next layout pass, when it has geometry, and `onScroll`
153
154
  * reports the move once that pass is over.
154
155
  */